diff --git a/README.md b/README.md index 62d11370..e62a9204 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/api/pom.xml b/api/pom.xml index 7a132539..40562522 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -50,14 +50,14 @@ xyz.gianlu.librespot - librespot-core + librespot-player ${project.version} io.undertow undertow-core - 2.1.0.Final + 2.1.3.Final \ No newline at end of file diff --git a/api/src/main/java/xyz/gianlu/librespot/api/ApiServer.java b/api/src/main/java/xyz/gianlu/librespot/api/ApiServer.java index 79dd3480..1d5f5952 100644 --- a/api/src/main/java/xyz/gianlu/librespot/api/ApiServer.java +++ b/api/src/main/java/xyz/gianlu/librespot/api/ApiServer.java @@ -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; @@ -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); } diff --git a/api/src/main/java/xyz/gianlu/librespot/api/Main.java b/api/src/main/java/xyz/gianlu/librespot/api/Main.java index 7b969c2a..6a5dda7a 100644 --- a/api/src/main/java/xyz/gianlu/librespot/api/Main.java +++ b/api/src/main/java/xyz/gianlu/librespot/api/Main.java @@ -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; @@ -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(); } diff --git a/api/src/main/java/xyz/gianlu/librespot/api/PlayerApiServer.java b/api/src/main/java/xyz/gianlu/librespot/api/PlayerApiServer.java new file mode 100644 index 00000000..ed7c92a3 --- /dev/null +++ b/api/src/main/java/xyz/gianlu/librespot/api/PlayerApiServer.java @@ -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); + } +} diff --git a/api/src/main/java/xyz/gianlu/librespot/api/PlayerWrapper.java b/api/src/main/java/xyz/gianlu/librespot/api/PlayerWrapper.java new file mode 100644 index 00000000..88a92216 --- /dev/null +++ b/api/src/main/java/xyz/gianlu/librespot/api/PlayerWrapper.java @@ -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 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); + } +} diff --git a/api/src/main/java/xyz/gianlu/librespot/api/SessionWrapper.java b/api/src/main/java/xyz/gianlu/librespot/api/SessionWrapper.java index 9e194050..ebd91f50 100644 --- a/api/src/main/java/xyz/gianlu/librespot/api/SessionWrapper.java +++ b/api/src/main/java/xyz/gianlu/librespot/api/SessionWrapper.java @@ -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 ref = new AtomicReference<>(null); +public class SessionWrapper { + protected final AtomicReference sessionRef = new AtomicReference<>(null); private Listener listener = null; - private SessionWrapper() { + protected SessionWrapper() { } /** @@ -39,7 +39,7 @@ 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; } @@ -47,24 +47,24 @@ 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(); diff --git a/api/src/main/java/xyz/gianlu/librespot/api/handlers/AbsPlayerHandler.java b/api/src/main/java/xyz/gianlu/librespot/api/handlers/AbsPlayerHandler.java new file mode 100644 index 00000000..972439e0 --- /dev/null +++ b/api/src/main/java/xyz/gianlu/librespot/api/handlers/AbsPlayerHandler.java @@ -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; +} diff --git a/api/src/main/java/xyz/gianlu/librespot/api/handlers/AbsSessionHandler.java b/api/src/main/java/xyz/gianlu/librespot/api/handlers/AbsSessionHandler.java index 0d75334d..9fac8732 100644 --- a/api/src/main/java/xyz/gianlu/librespot/api/handlers/AbsSessionHandler.java +++ b/api/src/main/java/xyz/gianlu/librespot/api/handlers/AbsSessionHandler.java @@ -20,24 +20,24 @@ public AbsSessionHandler(@NotNull SessionWrapper wrapper) { @Override public final void handleRequest(HttpServerExchange exchange) throws Exception { - Session s = wrapper.get(); - if (s == null) { + Session session = wrapper.getSession(); + if (session == null) { exchange.setStatusCode(StatusCodes.NO_CONTENT); return; } - if (s.reconnecting()) { + if (session.reconnecting()) { exchange.setStatusCode(StatusCodes.SERVICE_UNAVAILABLE); exchange.getResponseHeaders().add(Headers.RETRY_AFTER, 10); return; } - if (!s.isValid()) { + if (!session.isValid()) { exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR); return; } - handleRequest(exchange, s); + handleRequest(exchange, session); } protected abstract void handleRequest(@NotNull HttpServerExchange exchange, @NotNull Session session) throws Exception; diff --git a/api/src/main/java/xyz/gianlu/librespot/api/handlers/EventsHandler.java b/api/src/main/java/xyz/gianlu/librespot/api/handlers/EventsHandler.java index 53d4872a..0ee9eef0 100644 --- a/api/src/main/java/xyz/gianlu/librespot/api/handlers/EventsHandler.java +++ b/api/src/main/java/xyz/gianlu/librespot/api/handlers/EventsHandler.java @@ -10,14 +10,14 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Range; -import xyz.gianlu.librespot.api.SessionWrapper; +import xyz.gianlu.librespot.api.PlayerWrapper; import xyz.gianlu.librespot.common.ProtobufToJson; import xyz.gianlu.librespot.core.Session; -import xyz.gianlu.librespot.mercury.model.PlayableId; +import xyz.gianlu.librespot.metadata.PlayableId; import xyz.gianlu.librespot.player.Player; import xyz.gianlu.librespot.player.TrackOrEpisode; -public final class EventsHandler extends WebSocketProtocolHandshakeHandler implements Player.EventsListener, SessionWrapper.Listener, Session.ReconnectionListener { +public final class EventsHandler extends WebSocketProtocolHandshakeHandler implements Player.EventsListener, PlayerWrapper.Listener, Session.ReconnectionListener { private static final Logger LOGGER = LogManager.getLogger(EventsHandler.class); public EventsHandler() { @@ -117,7 +117,6 @@ public void onPanicState() { @Override public void onSessionCleared(@NotNull Session old) { - old.player().removeEventsListener(this); old.removeReconnectionListener(this); JsonObject obj = new JsonObject(); @@ -125,6 +124,11 @@ public void onSessionCleared(@NotNull Session old) { dispatch(obj); } + @Override + public void onPlayerCleared(@NotNull Player old) { + old.removeEventsListener(this); + } + @Override public void onNewSession(@NotNull Session session) { JsonObject obj = new JsonObject(); @@ -132,10 +136,14 @@ public void onNewSession(@NotNull Session session) { obj.addProperty("username", session.username()); dispatch(obj); - session.player().addEventsListener(this); session.addReconnectionListener(this); } + @Override + public void onNewPlayer(@NotNull Player player) { + player.addEventsListener(this); + } + @Override public void onConnectionDropped() { JsonObject obj = new JsonObject(); diff --git a/api/src/main/java/xyz/gianlu/librespot/api/handlers/MetadataHandler.java b/api/src/main/java/xyz/gianlu/librespot/api/handlers/MetadataHandler.java index aa82e586..8f408037 100644 --- a/api/src/main/java/xyz/gianlu/librespot/api/handlers/MetadataHandler.java +++ b/api/src/main/java/xyz/gianlu/librespot/api/handlers/MetadataHandler.java @@ -13,7 +13,7 @@ import xyz.gianlu.librespot.dealer.ApiClient; import xyz.gianlu.librespot.mercury.MercuryClient; import xyz.gianlu.librespot.mercury.MercuryRequests; -import xyz.gianlu.librespot.mercury.model.*; +import xyz.gianlu.librespot.metadata.*; import java.io.IOException; import java.util.Deque; diff --git a/api/src/main/java/xyz/gianlu/librespot/api/handlers/PlayerHandler.java b/api/src/main/java/xyz/gianlu/librespot/api/handlers/PlayerHandler.java index 959e9638..02aff855 100644 --- a/api/src/main/java/xyz/gianlu/librespot/api/handlers/PlayerHandler.java +++ b/api/src/main/java/xyz/gianlu/librespot/api/handlers/PlayerHandler.java @@ -4,13 +4,13 @@ import io.undertow.server.HttpServerExchange; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.api.SessionWrapper; +import xyz.gianlu.librespot.api.PlayerWrapper; import xyz.gianlu.librespot.api.Utils; import xyz.gianlu.librespot.common.ProtobufToJson; import xyz.gianlu.librespot.core.Session; -import xyz.gianlu.librespot.mercury.model.EpisodeId; -import xyz.gianlu.librespot.mercury.model.PlayableId; -import xyz.gianlu.librespot.mercury.model.TrackId; +import xyz.gianlu.librespot.metadata.EpisodeId; +import xyz.gianlu.librespot.metadata.PlayableId; +import xyz.gianlu.librespot.metadata.TrackId; import xyz.gianlu.librespot.player.Player; import xyz.gianlu.librespot.player.TrackOrEpisode; @@ -18,13 +18,13 @@ import java.util.Map; import java.util.Objects; -public final class PlayerHandler extends AbsSessionHandler { +public final class PlayerHandler extends AbsPlayerHandler { - public PlayerHandler(@NotNull SessionWrapper wrapper) { + public PlayerHandler(@NotNull PlayerWrapper wrapper) { super(wrapper); } - private static void setVolume(HttpServerExchange exchange, @NotNull Session session, @Nullable String valStr) { + private static void setVolume(HttpServerExchange exchange, @NotNull Player player, @Nullable String valStr) { if (valStr == null) { Utils.invalidParameter(exchange, "volume"); return; @@ -43,22 +43,22 @@ private static void setVolume(HttpServerExchange exchange, @NotNull Session sess return; } - session.player().setVolume(val); + player.setVolume(val); } - private static void load(HttpServerExchange exchange, @NotNull Session session, @Nullable String uri, boolean play) { + private static void load(HttpServerExchange exchange, @NotNull Player player, @Nullable String uri, boolean play) { if (uri == null) { Utils.invalidParameter(exchange, "uri"); return; } - session.player().load(uri, play); + player.load(uri, play); } - private static void current(HttpServerExchange exchange, @NotNull Session session) { + private static void current(HttpServerExchange exchange, @NotNull Player player) { PlayableId id; try { - id = session.player().currentPlayable(); + id = player.currentPlayable(); } catch (IllegalStateException ex) { id = null; } @@ -66,10 +66,10 @@ private static void current(HttpServerExchange exchange, @NotNull Session sessio JsonObject obj = new JsonObject(); if (id != null) obj.addProperty("current", id.toSpotifyUri()); - long time = session.player().time(); + long time = player.time(); obj.addProperty("trackTime", time); - TrackOrEpisode metadata = session.player().currentMetadata(); + TrackOrEpisode metadata = player.currentMetadata(); if (id instanceof TrackId) { if (metadata == null || metadata.track == null) { Utils.internalError(exchange, "Missing track metadata. Try again."); @@ -92,8 +92,8 @@ private static void current(HttpServerExchange exchange, @NotNull Session sessio exchange.getResponseSender().send(obj.toString()); } - private static void tracks(HttpServerExchange exchange, @NotNull Session session, boolean withQueue) { - Player.Tracks tracks = session.player().tracks(withQueue); + private static void tracks(@NotNull HttpServerExchange exchange, @NotNull Player player, boolean withQueue) { + Player.Tracks tracks = player.tracks(withQueue); JsonObject obj = new JsonObject(); obj.add("current", tracks.current == null ? null : ProtobufToJson.convert(tracks.current)); @@ -102,25 +102,25 @@ private static void tracks(HttpServerExchange exchange, @NotNull Session session exchange.getResponseSender().send(obj.toString()); } - private static void addToQueue(HttpServerExchange exchange, @NotNull Session session, String uri) { + private static void addToQueue(HttpServerExchange exchange, @NotNull Player player, String uri) { if (uri == null) { Utils.invalidParameter(exchange, "uri"); return; } - session.player().addToQueue(uri); + player.addToQueue(uri); } - private static void removeFromQueue(HttpServerExchange exchange, @NotNull Session session, String uri) { + private static void removeFromQueue(HttpServerExchange exchange, @NotNull Player player, String uri) { if (uri == null) { Utils.invalidParameter(exchange, "uri"); return; } - session.player().removeFromQueue(uri); + player.removeFromQueue(uri); } - private static void seek(HttpServerExchange exchange, @NotNull Session session, @Nullable String valStr) { + private static void seek(HttpServerExchange exchange, @NotNull Player player, @Nullable String valStr) { if (valStr == null) { Utils.invalidParameter(exchange, "pos"); return; @@ -139,11 +139,11 @@ private static void seek(HttpServerExchange exchange, @NotNull Session session, return; } - session.player().seek(pos); + player.seek(pos); } @Override - protected void handleRequest(@NotNull HttpServerExchange exchange, @NotNull Session session) throws Exception { + protected void handleRequest(@NotNull HttpServerExchange exchange, @NotNull Session session, @NotNull Player player) throws Exception { exchange.startBlocking(); if (exchange.isInIoThread()) { exchange.dispatch(this); @@ -165,46 +165,46 @@ protected void handleRequest(@NotNull HttpServerExchange exchange, @NotNull Sess switch (cmd) { case CURRENT: - current(exchange, session); + current(exchange, player); return; case SET_VOLUME: - setVolume(exchange, session, Utils.getFirstString(params, "volume")); + setVolume(exchange, player, Utils.getFirstString(params, "volume")); return; case VOLUME_UP: - session.player().volumeUp(); + player.volumeUp(); return; case VOLUME_DOWN: - session.player().volumeDown(); + player.volumeDown(); return; case LOAD: - load(exchange, session, Utils.getFirstString(params, "uri"), Utils.getFirstBoolean(params, "play")); + load(exchange, player, Utils.getFirstString(params, "uri"), Utils.getFirstBoolean(params, "play")); return; case PLAY_PAUSE: - session.player().playPause(); + player.playPause(); return; case PAUSE: - session.player().pause(); + player.pause(); return; case RESUME: - session.player().play(); + player.play(); return; case PREV: - session.player().previous(); + player.previous(); return; case NEXT: - session.player().next(); + player.next(); return; case SEEK: - seek(exchange, session, Utils.getFirstString(params, "pos")); + seek(exchange, player, Utils.getFirstString(params, "pos")); return; case TRACKS: - tracks(exchange, session, Utils.getFirstBoolean(params, "withQueue")); + tracks(exchange, player, Utils.getFirstBoolean(params, "withQueue")); return; case ADD_TO_QUEUE: - addToQueue(exchange, session, Utils.getFirstString(params, "uri")); + addToQueue(exchange, player, Utils.getFirstString(params, "uri")); break; case REMOVE_FROM_QUEUE: - removeFromQueue(exchange, session, Utils.getFirstString(params, "uri")); + removeFromQueue(exchange, player, Utils.getFirstString(params, "uri")); break; default: throw new IllegalArgumentException(cmd.name()); diff --git a/core/src/main/java/xyz/gianlu/librespot/AbsConfiguration.java b/core/src/main/java/xyz/gianlu/librespot/AbsConfiguration.java deleted file mode 100644 index 99a22617..00000000 --- a/core/src/main/java/xyz/gianlu/librespot/AbsConfiguration.java +++ /dev/null @@ -1,34 +0,0 @@ -package xyz.gianlu.librespot; - -import com.spotify.connectstate.Connect; -import org.apache.logging.log4j.Level; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.api.ApiConfiguration; -import xyz.gianlu.librespot.cache.CacheManager; -import xyz.gianlu.librespot.core.AuthConfiguration; -import xyz.gianlu.librespot.core.Session; -import xyz.gianlu.librespot.core.TimeProvider; -import xyz.gianlu.librespot.core.ZeroconfServer; -import xyz.gianlu.librespot.player.Player; - -/** - * @author Gianlu - */ -public abstract class AbsConfiguration implements ApiConfiguration, Session.ProxyConfiguration, TimeProvider.Configuration, Player.Configuration, CacheManager.Configuration, AuthConfiguration, ZeroconfServer.Configuration { - - @Nullable - public abstract String deviceId(); - - @Nullable - public abstract String deviceName(); - - @Nullable - public abstract Connect.DeviceType deviceType(); - - @NotNull - public abstract String preferredLocale(); - - @NotNull - public abstract Level loggingLevel(); -} diff --git a/core/src/main/java/xyz/gianlu/librespot/api/ApiConfiguration.java b/core/src/main/java/xyz/gianlu/librespot/api/ApiConfiguration.java deleted file mode 100644 index 88742be3..00000000 --- a/core/src/main/java/xyz/gianlu/librespot/api/ApiConfiguration.java +++ /dev/null @@ -1,13 +0,0 @@ -package xyz.gianlu.librespot.api; - -import org.jetbrains.annotations.NotNull; - -/** - * Configuration parameters used for the `api` module - */ -public interface ApiConfiguration { - int apiPort(); - - @NotNull - String apiHost(); -} diff --git a/core/src/main/java/xyz/gianlu/librespot/core/AuthConfiguration.java b/core/src/main/java/xyz/gianlu/librespot/core/AuthConfiguration.java deleted file mode 100644 index 11eb5ccf..00000000 --- a/core/src/main/java/xyz/gianlu/librespot/core/AuthConfiguration.java +++ /dev/null @@ -1,38 +0,0 @@ -package xyz.gianlu.librespot.core; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.File; - -/** - * @author Gianlu - */ -public interface AuthConfiguration { - @Nullable - String authUsername(); - - @Nullable - String authPassword(); - - @Nullable - String authBlob(); - - @NotNull - Strategy authStrategy(); - - boolean storeCredentials(); - - @Nullable - File credentialsFile(); - - default boolean hasStoredCredentials() { - File file = credentialsFile(); - return storeCredentials() && file != null && file.exists() && file.canRead(); - } - - enum Strategy { - FACEBOOK, BLOB, - USER_PASS, ZEROCONF - } -} diff --git a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java b/core/src/main/java/xyz/gianlu/librespot/core/EventService.java deleted file mode 100644 index 977744e3..00000000 --- a/core/src/main/java/xyz/gianlu/librespot/core/EventService.java +++ /dev/null @@ -1,371 +0,0 @@ -package xyz.gianlu.librespot.core; - -import com.spotify.metadata.Metadata; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.common.AsyncWorker; -import xyz.gianlu.librespot.common.Utils; -import xyz.gianlu.librespot.connectstate.DeviceStateHandler; -import xyz.gianlu.librespot.crypto.Packet; -import xyz.gianlu.librespot.mercury.MercuryClient; -import xyz.gianlu.librespot.mercury.RawMercuryRequest; -import xyz.gianlu.librespot.mercury.model.PlayableId; -import xyz.gianlu.librespot.player.StateWrapper; -import xyz.gianlu.librespot.player.playback.PlayerMetrics; - -import java.io.ByteArrayOutputStream; -import java.io.Closeable; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; - -/** - * @author Gianlu - */ -public final class EventService implements Closeable { - private final static Logger LOGGER = LogManager.getLogger(EventService.class); - private final Session session; - private final AsyncWorker asyncWorker; - private long trackTransitionIncremental = 1; - - EventService(@NotNull Session session) { - this.session = session; - this.asyncWorker = new AsyncWorker<>("event-service-sender", eventBuilder -> { - try { - byte[] body = eventBuilder.toArray(); - MercuryClient.Response resp = session.mercury().sendSync(RawMercuryRequest.newBuilder() - .setUri("hm://event-service/v1/events").setMethod("POST") - .addUserField("Accept-Language", "en") - .addUserField("X-ClientTimeStamp", String.valueOf(TimeProvider.currentTimeMillis())) - .addPayloadPart(body) - .build()); - - LOGGER.debug("Event sent. {body: {}, result: {}}", EventBuilder.toString(body), resp.statusCode); - } catch (IOException ex) { - LOGGER.error("Failed sending event: " + eventBuilder, ex); - } - }); - } - - private void sendEvent(@NotNull EventBuilder builder) { - asyncWorker.submit(builder); - } - - /** - * Reports our language. - * - * @param lang The language (2 letters code) - */ - public void language(@NotNull String lang) { - EventBuilder event = new EventBuilder(Type.LANGUAGE); - event.append(lang); - sendEvent(event); - } - - private void trackTransition(@NotNull PlaybackMetrics metrics, @NotNull DeviceStateHandler device) { - int when = metrics.lastValue(); - - try { - session.send(Packet.Type.TrackEndedTime, ByteBuffer.allocate(5).put((byte) 1).putInt(when).array()); - } catch (IOException ex) { - LOGGER.error("Failed sending TrackEndedTime packet.", ex); - } - - EventBuilder event = new EventBuilder(Type.TRACK_TRANSITION); - event.append(String.valueOf(trackTransitionIncremental++)); - event.append(session.deviceId()); - event.append(metrics.playbackId).append("00000000000000000000000000000000"); - event.append(metrics.sourceStart).append(metrics.startedHow()); - event.append(metrics.sourceEnd).append(metrics.endedHow()); - event.append(String.valueOf(metrics.player.decodedLength)).append(String.valueOf(metrics.player.size)); - event.append(String.valueOf(when)).append(String.valueOf(when)); - event.append(String.valueOf(metrics.player.duration)); - event.append(String.valueOf(metrics.player.decryptTime)).append(String.valueOf(metrics.player.fadeOverlap)).append('0').append('0'); - event.append(metrics.firstValue() == 0 ? '0' : '1').append(String.valueOf(metrics.firstValue())); - event.append('0').append("-1").append("context"); - event.append(String.valueOf(metrics.player.contentMetrics.audioKeyTime)).append('0'); - event.append(metrics.player.contentMetrics.preloadedAudioKey ? '1' : '0').append('0').append('0').append('0'); - event.append(String.valueOf(when)).append(String.valueOf(when)); - event.append('0').append(String.valueOf(metrics.player.bitrate)); - event.append(metrics.contextUri).append(metrics.player.encoding); - event.append(metrics.id.hexId()).append(""); - event.append('0').append(String.valueOf(metrics.timestamp)).append('0'); - event.append("context").append(metrics.referrerIdentifier).append(metrics.featureVersion); - event.append("com.spotify").append(metrics.player.transition).append("none"); - event.append(device.getLastCommandSentByDeviceId()).append("na").append("none"); - sendEvent(event); - } - - public void trackPlayed(@NotNull PlaybackMetrics metrics, @NotNull DeviceStateHandler device) { - if (metrics.player == null || metrics.player.contentMetrics == null) { - LOGGER.warn("Did not send event because of missing metrics: " + metrics.playbackId); - return; - } - - trackTransition(metrics, device); - - - EventBuilder event = new EventBuilder(Type.CDN_REQUEST); - event.append(metrics.player.contentMetrics.fileId).append(metrics.playbackId); - event.append('0').append('0').append('0').append('0').append('0').append('0'); - event.append(String.valueOf(metrics.player.decodedLength)).append(String.valueOf(metrics.player.size)); - event.append("music").append("-1").append("-1").append("-1").append("-1.000000"); - event.append("-1").append("-1.000000").append("-1").append("-1").append("-1").append("-1.000000"); - event.append("-1").append("-1").append("-1").append("-1").append("-1.000000").append("-1"); - event.append("0.000000").append("-1.000000").append("").append("").append("unknown"); - event.append('0').append('0').append('0').append('0').append('0'); - event.append("interactive").append('0').append(String.valueOf(metrics.player.bitrate)).append('0').append('0'); - sendEvent(event); - - - EventBuilder anotherEvent = new EventBuilder(Type.TRACK_PLAYED); - anotherEvent.append(metrics.playbackId).append(metrics.id.toSpotifyUri()); - anotherEvent.append('0').append(metrics.intervalsToSend()); - sendEvent(anotherEvent); - } - - /** - * Reports that a new playback ID is being used. - * - * @param state The current player state - * @param playbackId The new playback ID - */ - public void newPlaybackId(@NotNull StateWrapper state, @NotNull String playbackId) { - EventBuilder event = new EventBuilder(Type.NEW_PLAYBACK_ID); - event.append(playbackId).append(state.getSessionId()).append(String.valueOf(TimeProvider.currentTimeMillis())); - sendEvent(event); - } - - /** - * Reports that a new session ID is being used. - * - * @param sessionId The session ID - * @param state The current player state - */ - public void newSessionId(@NotNull String sessionId, @NotNull StateWrapper state) { - String contextUri = state.getContextUri(); - - EventBuilder event = new EventBuilder(Type.NEW_SESSION_ID); - event.append(sessionId); - event.append(contextUri); - event.append(contextUri); - event.append(String.valueOf(TimeProvider.currentTimeMillis())); - event.append("").append(String.valueOf(state.getContextSize())); - event.append(state.getContextUrl()); - sendEvent(event); - } - - /** - * Reports that a file ID has been fetched for some content. - * - * @param id The content {@link PlayableId} - * @param file The {@link com.spotify.metadata.Metadata.AudioFile} for this content - */ - public void fetchedFileId(@NotNull PlayableId id, @NotNull Metadata.AudioFile file) { - EventBuilder event = new EventBuilder(Type.FETCHED_FILE_ID); - event.append('2').append('2'); - event.append(Utils.bytesToHex(file.getFileId()).toLowerCase()); - event.append(id.toSpotifyUri()); - event.append('1').append('2').append('2'); - sendEvent(event); - } - - @Override - public void close() { - asyncWorker.close(); - - try { - asyncWorker.awaitTermination(1, TimeUnit.SECONDS); - } catch (InterruptedException ignored) { - } - } - - private enum Type { - LANGUAGE("812", "1"), FETCHED_FILE_ID("274", "3"), NEW_SESSION_ID("557", "3"), - NEW_PLAYBACK_ID("558", "1"), TRACK_PLAYED("372", "1"), TRACK_TRANSITION("12", "37"), - CDN_REQUEST("10", "20"); - - private final String id; - private final String unknown; - - Type(@NotNull String id, @NotNull String unknown) { - this.id = id; - this.unknown = unknown; - } - } - - private static class EventBuilder { - private final ByteArrayOutputStream body = new ByteArrayOutputStream(256); - - EventBuilder(@NotNull Type type) { - appendNoDelimiter(type.id); - append(type.unknown); - } - - @NotNull - static String toString(byte[] body) { - StringBuilder result = new StringBuilder(); - for (byte b : body) { - if (b == 0x09) result.append('|'); - else result.append((char) b); - } - - return result.toString(); - } - - private void appendNoDelimiter(@Nullable String str) { - if (str == null) str = ""; - - try { - body.write(str.getBytes(StandardCharsets.UTF_8)); - } catch (IOException ex) { - throw new IllegalStateException(ex); - } - } - - @NotNull - EventBuilder append(char c) { - body.write(0x09); - body.write(c); - return this; - } - - @NotNull - EventBuilder append(@Nullable String str) { - body.write(0x09); - appendNoDelimiter(str); - return this; - } - - @Override - public String toString() { - return "EventBuilder{" + toString(toArray()) + '}'; - } - - @NotNull - byte[] toArray() { - return body.toByteArray(); - } - } - - public static class PlaybackMetrics { - public final PlayableId id; - final List intervals = new ArrayList<>(10); - final String playbackId; - final String featureVersion; - final String referrerIdentifier; - final String contextUri; - final long timestamp; - PlayerMetrics player = null; - Reason reasonStart = null; - String sourceStart = null; - Reason reasonEnd = null; - String sourceEnd = null; - Interval lastInterval = null; - - public PlaybackMetrics(@NotNull PlayableId id, @NotNull String playbackId, @NotNull StateWrapper state) { - this.id = id; - this.playbackId = playbackId; - this.contextUri = state.getContextUri(); - this.featureVersion = state.getPlayOrigin().getFeatureVersion(); - this.referrerIdentifier = state.getPlayOrigin().getReferrerIdentifier(); - this.timestamp = TimeProvider.currentTimeMillis(); - } - - @NotNull - String intervalsToSend() { - StringBuilder builder = new StringBuilder(); - builder.append('['); - - boolean first = true; - for (Interval interval : intervals) { - if (interval.begin == -1 || interval.end == -1) - continue; - - if (!first) builder.append(','); - builder.append('[').append(interval.begin).append(',').append(interval.end).append(']'); - first = false; - } - - builder.append(']'); - return builder.toString(); - } - - int firstValue() { - if (intervals.isEmpty()) return 0; - else return intervals.get(0).begin; - } - - int lastValue() { - if (intervals.isEmpty()) return player == null ? 0 : player.duration; - else return intervals.get(intervals.size() - 1).end; - } - - public void startInterval(int begin) { - lastInterval = new Interval(begin); - } - - public void endInterval(int end) { - if (lastInterval == null) return; - if (lastInterval.begin == end) { - lastInterval = null; - return; - } - - lastInterval.end = end; - intervals.add(lastInterval); - lastInterval = null; - } - - public void startedHow(@NotNull EventService.PlaybackMetrics.Reason reason, @Nullable String origin) { - reasonStart = reason; - sourceStart = origin == null || origin.isEmpty() ? "unknown" : origin; - } - - public void endedHow(@NotNull EventService.PlaybackMetrics.Reason reason, @Nullable String origin) { - reasonEnd = reason; - sourceEnd = origin == null || origin.isEmpty() ? "unknown" : origin; - } - - @Nullable - String startedHow() { - return reasonStart == null ? null : reasonStart.val; - } - - @Nullable - String endedHow() { - return reasonEnd == null ? null : reasonEnd.val; - } - - public void update(@Nullable PlayerMetrics playerMetrics) { - player = playerMetrics; - } - - public enum Reason { - TRACK_DONE("trackdone"), TRACK_ERROR("trackerror"), - FORWARD_BTN("fwdbtn"), BACK_BTN("backbtn"), - END_PLAY("endplay"), PLAY_BTN("playbtn"), CLICK_ROW("clickrow"), - LOGOUT("logout"), APP_LOAD("appload"), REMOTE("remote"); - - final String val; - - Reason(@NotNull String val) { - this.val = val; - } - } - - private static class Interval { - private final int begin; - private int end = -1; - - Interval(int begin) { - this.begin = begin; - } - } - } -} diff --git a/core/src/main/java/xyz/gianlu/librespot/player/ContentRestrictedException.java b/core/src/main/java/xyz/gianlu/librespot/player/ContentRestrictedException.java deleted file mode 100644 index 6e425814..00000000 --- a/core/src/main/java/xyz/gianlu/librespot/player/ContentRestrictedException.java +++ /dev/null @@ -1,41 +0,0 @@ -package xyz.gianlu.librespot.player; - -import com.spotify.metadata.Metadata; -import org.jetbrains.annotations.NotNull; - -import java.util.List; - -/** - * @author Gianlu - */ -public class ContentRestrictedException extends Exception { - - public static void checkRestrictions(@NotNull String country, @NotNull List restrictions) throws ContentRestrictedException { - for (Metadata.Restriction restriction : restrictions) - if (isRestricted(country, restriction)) - throw new ContentRestrictedException(); - } - - private static boolean isInList(@NotNull String list, @NotNull String match) { - for (int i = 0; i < list.length(); i += 2) - if (list.substring(i, i + 2).equals(match)) - return true; - - return false; - } - - private static boolean isRestricted(@NotNull String countryCode, @NotNull Metadata.Restriction restriction) { - if (restriction.hasCountriesAllowed()) { - String allowed = restriction.getCountriesAllowed(); - if (allowed.isEmpty()) return true; - - if (!isInList(restriction.getCountriesForbidden(), countryCode)) - return true; - } - - if (restriction.hasCountriesForbidden()) - return isInList(restriction.getCountriesForbidden(), countryCode); - - return false; - } -} diff --git a/common/.gitignore b/lib/.gitignore similarity index 100% rename from common/.gitignore rename to lib/.gitignore diff --git a/lib/README.md b/lib/README.md new file mode 100644 index 00000000..63fbf2d8 --- /dev/null +++ b/lib/README.md @@ -0,0 +1,68 @@ +# Library +This module contains all the necessary components to interact with the Spotify infrastructure, but doesn't require configuration files or additional system resources. + +## Getting started +The core of all components is the [Session class](src/main/java/xyz/gianlu/librespot/core/Session.java), it takes care of connecting, authenticating and setting everything up. + +```java +Session.Configuration conf = new Session.Configuration.Builder() + .setCacheEnabled() + .setCacheDir() + .setDoCacheCleanUp() + .setStoreCredentials() + .setStoredCredentialsFile() + .setTimeSynchronizationMethod() + .setTimeManualCorrection() + .setProxyEnabled() + .setProxyType() + .setProxyAddress() + .setProxyPort() + .setProxyAuth() + .setProxyUsername() + .setProxyPassword() + .setRetryOnChunkError() + .build(); + + +Session.Builder builder = new Session.Builder(conf) + .setPreferredLocale() + .setDeviceType() + .setDeviceName() + .setDeviceId(); + +builder.userPass("", ""); // See other authentication methods + +Session session = builder.create(); + +session.mercury(); // Mercury client +session.audioKey(); // Request audio keys for AES decryption +session.cdn(); // Request content from CDN +session.tokens(); // Request access tokens +session.api(); // Request metadata and other data +session.contentFeeder(); // Request tracks, images, etc +session.search(); // Perform search +``` + +You can also instantiate the player: +```java +PlayerConfiguration conf = new PlayerConfiguration.Builder() + .setAutoplayEnabled() + .setCrossfadeDuration() + .setEnableNormalisation() + .setInitialVolume() + .setLogAvailableMixers() + .setMetadataPipe() + .setMixerSearchKeywords() + .setNormalisationPregain() + .setOutput() + .setOutputPipe() + .setPreferredQuality() + .setPreloadEnabled() + .setReleaseLineDelay() + .setVolumeSteps() + .build(); + +Player player = new Player(conf, session); +``` + +A proper implementation is available in the [Main class](../player/src/main/java/xyz/gianlu/librespot/player/Main.java) of the player. \ No newline at end of file diff --git a/common/pom.xml b/lib/pom.xml similarity index 81% rename from common/pom.xml rename to lib/pom.xml index d2840532..02ce4f27 100644 --- a/common/pom.xml +++ b/lib/pom.xml @@ -9,23 +9,10 @@ ../ - librespot-common - librespot-java common + librespot-lib jar - - - - com.google.protobuf - protobuf-java - ${protobuf.version} - - - com.google.code.gson - gson - ${gson.version} - - + librespot-java lib @@ -78,4 +65,39 @@ + + + + + com.google.protobuf + protobuf-java + ${protobuf.version} + + + com.google.code.gson + gson + ${gson.version} + + + + + xyz.gianlu.zeroconf + zeroconf + 1.1.3 + + + + + com.squareup.okhttp3 + okhttp + 4.8.0 + + + + + commons-net + commons-net + 3.6 + + \ No newline at end of file diff --git a/core/src/main/java/xyz/gianlu/librespot/Version.java b/lib/src/main/java/xyz/gianlu/librespot/Version.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/Version.java rename to lib/src/main/java/xyz/gianlu/librespot/Version.java diff --git a/core/src/main/java/xyz/gianlu/librespot/core/ZeroconfServer.java b/lib/src/main/java/xyz/gianlu/librespot/ZeroconfServer.java similarity index 85% rename from core/src/main/java/xyz/gianlu/librespot/core/ZeroconfServer.java rename to lib/src/main/java/xyz/gianlu/librespot/ZeroconfServer.java index 93f7c5fd..8ac79253 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/ZeroconfServer.java +++ b/lib/src/main/java/xyz/gianlu/librespot/ZeroconfServer.java @@ -1,16 +1,16 @@ -package xyz.gianlu.librespot.core; +package xyz.gianlu.librespot; import com.google.gson.JsonObject; -import com.spotify.Authentication; +import com.spotify.connectstate.Connect; import okhttp3.HttpUrl; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NonNls; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.AbsConfiguration; -import xyz.gianlu.librespot.Version; import xyz.gianlu.librespot.common.NameThreadFactory; import xyz.gianlu.librespot.common.Utils; +import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.crypto.DiffieHellman; import xyz.gianlu.librespot.mercury.MercuryClient; import xyz.gianlu.zeroconf.Service; @@ -28,6 +28,7 @@ import java.net.*; import java.security.GeneralSecurityException; import java.security.MessageDigest; +import java.security.SecureRandom; import java.util.*; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -37,8 +38,8 @@ * @author Gianlu */ public class ZeroconfServer implements Closeable { - public final static int MAX_PORT = 65536; - public final static int MIN_PORT = 1024; + private final static int MAX_PORT = 65536; + private final static int MIN_PORT = 1024; private static final Logger LOGGER = LogManager.getLogger(ZeroconfServer.class); private static final byte[] EOL = new byte[]{'\r', '\n'}; private static final JsonObject DEFAULT_GET_INFO_FIELDS = new JsonObject(); @@ -90,42 +91,40 @@ public class ZeroconfServer implements Closeable { } private final HttpRunner runner; - private final Session.Inner inner; private final DiffieHellman keys; private final List sessionListeners; private final Zeroconf zeroconf; private final Object connectionLock = new Object(); + private final Inner inner; private volatile Session session; private String connectingUsername = null; - private ZeroconfServer(Session.Inner inner, Configuration conf) throws IOException { + private ZeroconfServer(@NotNull Inner inner, int listenPort, boolean listenAllInterfaces, String[] interfacesList) throws IOException { this.inner = inner; this.keys = new DiffieHellman(inner.random); this.sessionListeners = new ArrayList<>(); - int port = conf.zeroconfListenPort(); - if (port == -1) - port = inner.random.nextInt((MAX_PORT - MIN_PORT) + 1) + MIN_PORT; + if (listenPort == -1) + listenPort = inner.random.nextInt((MAX_PORT - MIN_PORT) + 1) + MIN_PORT; - new Thread(this.runner = new HttpRunner(port), "zeroconf-http-server").start(); + new Thread(this.runner = new HttpRunner(listenPort), "zeroconf-http-server").start(); List nics; - if (conf.zeroconfListenAll()) { + if (listenAllInterfaces) { nics = getAllInterfaces(); } else { - String[] interfaces = conf.zeroconfInterfaces(); - if (interfaces == null || interfaces.length == 0) { + if (interfacesList == null || interfacesList.length == 0) { nics = getAllInterfaces(); } else { nics = new ArrayList<>(); - for (String str : interfaces) { + for (String str : interfacesList) { NetworkInterface nif = NetworkInterface.getByName(str); if (nif == null) { LOGGER.warn("Interface {} doesn't exists.", str); continue; } - checkInterface(nics, nif, true); + checkInterface(nics, nif); } } } @@ -139,7 +138,7 @@ private ZeroconfServer(Session.Inner inner, Configuration conf) throws IOExcepti txt.put("CPath", "/"); txt.put("VERSION", "1.0"); txt.put("Stack", "SP"); - Service service = new Service(inner.deviceName, "spotify-connect", port); + Service service = new Service(inner.deviceName, "spotify-connect", listenPort); service.setText(txt); zeroconf.announce(service); @@ -157,12 +156,6 @@ public static String getUsefulHostname() throws UnknownHostException { return host; } - @NotNull - public static ZeroconfServer create(@NotNull AbsConfiguration conf) throws IOException { - ApResolver.fillPool(); - return new ZeroconfServer(Session.Inner.from(conf), conf); - } - private static boolean isVirtual(@NotNull NetworkInterface nif) throws SocketException { byte[] mac = nif.getHardwareAddress(); if (mac == null) return true; @@ -180,16 +173,8 @@ private static boolean isVirtual(@NotNull NetworkInterface nif) throws SocketExc return false; } - private static void checkInterface(List list, @NotNull NetworkInterface nif, boolean checkVirtual) throws SocketException { - if (nif.isLoopback()) return; - - if (isVirtual(nif)) { - if (checkVirtual) - return; - else - LOGGER.warn("Interface {} is suspected to be virtual, mac: {}", nif.getName(), Utils.bytesToHex(nif.getHardwareAddress())); - } - + private static void checkInterface(List list, @NotNull NetworkInterface nif) throws SocketException { + if (nif.isLoopback() || isVirtual(nif)) return; list.add(nif); } @@ -197,7 +182,7 @@ private static void checkInterface(List list, @NotNull Network private static List getAllInterfaces() throws SocketException { List list = new ArrayList<>(); Enumeration is = NetworkInterface.getNetworkInterfaces(); - while (is.hasMoreElements()) checkInterface(list, is.nextElement(), true); + while (is.hasMoreElements()) checkInterface(list, is.nextElement()); return list; } @@ -339,10 +324,7 @@ private void handleAddUser(OutputStream out, Map params, String connectingUsername = username; } - Authentication.LoginCredentials credentials = inner.decryptBlob(username, decrypted); - - session = Session.from(inner); - LOGGER.info("Accepted new user from {}. {deviceId: {}}", params.get("deviceName"), session.deviceId()); + LOGGER.info("Accepted new user from {}. {deviceId: {}}", params.get("deviceName"), inner.deviceId); // Sending response String resp = DEFAULT_SUCCESSFUL_ADD_USER.toString(); @@ -358,9 +340,13 @@ private void handleAddUser(OutputStream out, Map params, String out.write(resp.getBytes()); out.flush(); - - session.connect(); - session.authenticate(credentials); + session = new Session.Builder(inner.conf) + .setDeviceId(inner.deviceId) + .setDeviceName(inner.deviceName) + .setDeviceType(inner.deviceType) + .setPreferredLocale(inner.preferredLocale) + .blob(username, decrypted) + .create(); synchronized (connectionLock) { connectingUsername = null; @@ -390,17 +376,60 @@ public void removeSessionListener(@NotNull SessionListener listener) { sessionListeners.remove(listener); } - public interface Configuration { - boolean zeroconfListenAll(); + public interface SessionListener { + void sessionChanged(@NotNull Session session); + } + + public static class Builder extends Session.AbsBuilder { + private boolean listenAll = true; + private int listenPort = -1; + private String[] listenInterfaces = null; - int zeroconfListenPort(); + public Builder(Session.@NotNull Configuration conf) { + super(conf); + } + + public Builder() { + } + + public Builder setListenAll(boolean listenAll) { + this.listenAll = listenAll; + this.listenInterfaces = null; + return this; + } + + public Builder setListenPort(int listenPort) { + this.listenPort = listenPort; + return this; + } - @Nullable - String[] zeroconfInterfaces(); + public Builder setListenInterfaces(@NotNull String[] listenInterfaces) { + this.listenAll = false; + this.listenInterfaces = listenInterfaces; + return this; + } + + @NonNls + public ZeroconfServer create() throws IOException { + return new ZeroconfServer(new Inner(deviceType, deviceName, deviceId, preferredLocale, conf), listenPort, listenAll, listenInterfaces); + } } - public interface SessionListener { - void sessionChanged(@NotNull Session session); + private static class Inner { + final Random random = new SecureRandom(); + final Connect.DeviceType deviceType; + final String deviceName; + final String deviceId; + final String preferredLocale; + final Session.Configuration conf; + + Inner(@NotNull Connect.DeviceType deviceType, @NotNull String deviceName, @Nullable String deviceId, @NotNull String preferredLocale, @NotNull Session.Configuration conf) { + this.deviceType = deviceType; + this.deviceName = deviceName; + this.deviceId = deviceId; + this.preferredLocale = preferredLocale; + this.conf = conf; + } } private class HttpRunner implements Runnable, Closeable { diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/AbsChunkedInputStream.java b/lib/src/main/java/xyz/gianlu/librespot/audio/AbsChunkedInputStream.java similarity index 94% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/AbsChunkedInputStream.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/AbsChunkedInputStream.java index f836242e..b842f8ad 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/AbsChunkedInputStream.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/AbsChunkedInputStream.java @@ -1,12 +1,11 @@ -package xyz.gianlu.librespot.player.feeders; +package xyz.gianlu.librespot.audio; import org.jetbrains.annotations.NotNull; -import xyz.gianlu.librespot.player.Player; import java.io.IOException; import java.io.InputStream; -import static xyz.gianlu.librespot.player.feeders.storage.ChannelManager.CHUNK_SIZE; +import static xyz.gianlu.librespot.audio.storage.ChannelManager.CHUNK_SIZE; /** * @author Gianlu @@ -17,7 +16,7 @@ public abstract class AbsChunkedInputStream extends InputStream implements HaltL private static final int MAX_CHUNK_TRIES = 128; private final Object waitLock = new Object(); private final int[] retries; - private final boolean stopPlaybackOnChunkError; + private final boolean retryOnChunkError; private volatile int waitForChunk = -1; private volatile ChunkException chunkException = null; private int pos = 0; @@ -25,9 +24,9 @@ public abstract class AbsChunkedInputStream extends InputStream implements HaltL private volatile boolean closed = false; private int decodedLength = 0; - protected AbsChunkedInputStream(@NotNull Player.Configuration conf) { + protected AbsChunkedInputStream(boolean retryOnChunkError) { this.retries = new int[chunks()]; - this.stopPlaybackOnChunkError = conf.stopPlaybackOnChunkError(); + this.retryOnChunkError = retryOnChunkError; } public final boolean isClosed() { @@ -114,7 +113,7 @@ public final synchronized long skip(long n) throws IOException { private boolean shouldRetry(int chunk) { if (retries[chunk] < 1) return true; if (retries[chunk] > MAX_CHUNK_TRIES) return false; - return !stopPlaybackOnChunkError; + return !retryOnChunkError; } /** diff --git a/core/src/main/java/xyz/gianlu/librespot/player/AudioKeyManager.java b/lib/src/main/java/xyz/gianlu/librespot/audio/AudioKeyManager.java similarity index 99% rename from core/src/main/java/xyz/gianlu/librespot/player/AudioKeyManager.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/AudioKeyManager.java index 636c972a..d3d23fa5 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/AudioKeyManager.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/AudioKeyManager.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player; +package xyz.gianlu.librespot.audio; import com.google.protobuf.ByteString; import org.apache.logging.log4j.LogManager; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/GeneralAudioStream.java b/lib/src/main/java/xyz/gianlu/librespot/audio/GeneralAudioStream.java similarity index 71% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/GeneralAudioStream.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/GeneralAudioStream.java index c22cc766..a0f605b2 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/GeneralAudioStream.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/GeneralAudioStream.java @@ -1,7 +1,8 @@ -package xyz.gianlu.librespot.player.feeders; +package xyz.gianlu.librespot.audio; import org.jetbrains.annotations.NotNull; -import xyz.gianlu.librespot.player.codecs.SuperAudioFormat; +import xyz.gianlu.librespot.audio.format.SuperAudioFormat; + /** * @author Gianlu diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/GeneralWritableStream.java b/lib/src/main/java/xyz/gianlu/librespot/audio/GeneralWritableStream.java similarity index 80% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/GeneralWritableStream.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/GeneralWritableStream.java index 6c45f99b..229b6cf3 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/GeneralWritableStream.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/GeneralWritableStream.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.feeders; +package xyz.gianlu.librespot.audio; import java.io.IOException; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/HaltListener.java b/lib/src/main/java/xyz/gianlu/librespot/audio/HaltListener.java similarity index 78% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/HaltListener.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/HaltListener.java index 018b0d88..b1a5da8f 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/HaltListener.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/HaltListener.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.feeders; +package xyz.gianlu.librespot.audio; /** * @author Gianlu diff --git a/core/src/main/java/xyz/gianlu/librespot/player/codecs/NormalizationData.java b/lib/src/main/java/xyz/gianlu/librespot/audio/NormalizationData.java similarity index 90% rename from core/src/main/java/xyz/gianlu/librespot/player/codecs/NormalizationData.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/NormalizationData.java index 4fbaace1..518d472d 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/codecs/NormalizationData.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/NormalizationData.java @@ -1,10 +1,9 @@ -package xyz.gianlu.librespot.player.codecs; +package xyz.gianlu.librespot.audio; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; -import xyz.gianlu.librespot.player.Player; import java.io.DataInputStream; import java.io.IOException; @@ -47,8 +46,8 @@ public static NormalizationData read(@NotNull InputStream in) throws IOException return new NormalizationData(buffer.getFloat(), buffer.getFloat(), buffer.getFloat(), buffer.getFloat()); } - public float getFactor(@NotNull Player.Configuration config) { - float normalisationFactor = (float) Math.pow(10, (track_gain_db + config.normalisationPregain()) / 20); + public float getFactor(float normalisationPregain) { + float normalisationFactor = (float) Math.pow(10, (track_gain_db + normalisationPregain) / 20); if (normalisationFactor * track_peak > 1) { LOGGER.warn("Reducing normalisation factor to prevent clipping. Please add negative pregain to avoid."); normalisationFactor = 1 / track_peak; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/PlayableContentFeeder.java b/lib/src/main/java/xyz/gianlu/librespot/audio/PlayableContentFeeder.java similarity index 66% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/PlayableContentFeeder.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/PlayableContentFeeder.java index 4246e436..72e52f69 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/PlayableContentFeeder.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/PlayableContentFeeder.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.feeders; +package xyz.gianlu.librespot.audio; import com.google.protobuf.ByteString; import com.spotify.metadata.Metadata; @@ -11,21 +11,21 @@ import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.audio.cdn.CdnFeedHelper; +import xyz.gianlu.librespot.audio.cdn.CdnManager; +import xyz.gianlu.librespot.audio.format.AudioQualityPicker; +import xyz.gianlu.librespot.audio.storage.AudioFileFetch; +import xyz.gianlu.librespot.audio.storage.StorageFeedHelper; import xyz.gianlu.librespot.common.Utils; +import xyz.gianlu.librespot.core.EventService; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; -import xyz.gianlu.librespot.mercury.model.EpisodeId; -import xyz.gianlu.librespot.mercury.model.PlayableId; -import xyz.gianlu.librespot.mercury.model.TrackId; -import xyz.gianlu.librespot.player.ContentRestrictedException; -import xyz.gianlu.librespot.player.codecs.AudioQualityPreference; -import xyz.gianlu.librespot.player.codecs.NormalizationData; -import xyz.gianlu.librespot.player.feeders.cdn.CdnFeedHelper; -import xyz.gianlu.librespot.player.feeders.cdn.CdnManager; -import xyz.gianlu.librespot.player.feeders.storage.AudioFileFetch; -import xyz.gianlu.librespot.player.feeders.storage.StorageFeedHelper; +import xyz.gianlu.librespot.metadata.EpisodeId; +import xyz.gianlu.librespot.metadata.PlayableId; +import xyz.gianlu.librespot.metadata.TrackId; import java.io.IOException; +import java.util.List; /** * @author Gianlu @@ -57,15 +57,16 @@ private static Metadata.Track pickAlternativeIfNecessary(@NotNull Metadata.Track } @NotNull - public final LoadedStream load(@NotNull PlayableId id, @NotNull AudioQualityPreference audioQualityPreference, boolean preload, @Nullable HaltListener haltListener) throws CdnManager.CdnException, ContentRestrictedException, MercuryClient.MercuryException, IOException { + public final LoadedStream load(@NotNull PlayableId id, @NotNull AudioQualityPicker audioQualityPicker, boolean preload, @Nullable HaltListener haltListener) throws CdnManager.CdnException, ContentRestrictedException, MercuryClient.MercuryException, IOException { if (id instanceof TrackId) - return loadTrack((TrackId) id, audioQualityPreference, preload, haltListener); + return loadTrack((TrackId) id, audioQualityPicker, preload, haltListener); else if (id instanceof EpisodeId) - return loadEpisode((EpisodeId) id, audioQualityPreference, preload, haltListener); + return loadEpisode((EpisodeId) id, audioQualityPicker, preload, haltListener); else throw new IllegalArgumentException("Unknown content: " + id); } + @NotNull private StorageResolveResponse resolveStorageInteractive(@NotNull ByteString fileId, boolean preload) throws IOException, MercuryClient.MercuryException { try (Response resp = session.api().send("GET", String.format(preload ? STORAGE_RESOLVE_INTERACTIVE_PREFETCH : STORAGE_RESOLVE_INTERACTIVE, Utils.bytesToHex(fileId)), null, null)) { @@ -78,7 +79,7 @@ private StorageResolveResponse resolveStorageInteractive(@NotNull ByteString fil } } - private @NotNull LoadedStream loadTrack(@NotNull TrackId id, @NotNull AudioQualityPreference audioQualityPreference, boolean preload, @Nullable HaltListener haltListener) throws IOException, MercuryClient.MercuryException, ContentRestrictedException, CdnManager.CdnException { + private @NotNull LoadedStream loadTrack(@NotNull TrackId id, @NotNull AudioQualityPicker audioQualityPicker, boolean preload, @Nullable HaltListener haltListener) throws IOException, MercuryClient.MercuryException, ContentRestrictedException, CdnManager.CdnException { Metadata.Track original = session.api().getMetadata4Track(id); Metadata.Track track = pickAlternativeIfNecessary(original); if (track == null) { @@ -89,7 +90,7 @@ private StorageResolveResponse resolveStorageInteractive(@NotNull ByteString fil throw new FeederException(); } - return loadTrack(track, audioQualityPreference, preload, haltListener); + return loadTrack(track, audioQualityPicker, preload, haltListener); } @NotNull @@ -109,7 +110,7 @@ private LoadedStream loadStream(@NotNull Metadata.AudioFile file, @Nullable Meta if (track == null && episode == null) throw new IllegalStateException(); - session.eventService().fetchedFileId(track != null ? PlayableId.from(track) : PlayableId.from(episode), file); + session.eventService().sendEvent(new FetchedFileIdEvent(track != null ? PlayableId.from(track) : PlayableId.from(episode), file.getFileId())); StorageResolveResponse resp = resolveStorageInteractive(file.getFileId(), preload); switch (resp.getResult()) { @@ -134,8 +135,8 @@ private LoadedStream loadStream(@NotNull Metadata.AudioFile file, @Nullable Meta } @NotNull - private LoadedStream loadTrack(@NotNull Metadata.Track track, @NotNull AudioQualityPreference audioQualityPreference, boolean preload, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException, MercuryClient.MercuryException { - Metadata.AudioFile file = audioQualityPreference.getFile(track.getFileList()); + private LoadedStream loadTrack(@NotNull Metadata.Track track, @NotNull AudioQualityPicker audioQualityPicker, boolean preload, @Nullable HaltListener haltListener) throws IOException, CdnManager.CdnException, MercuryClient.MercuryException { + Metadata.AudioFile file = audioQualityPicker.getFile(track.getFileList()); if (file == null) { LOGGER.fatal("Couldn't find any suitable audio file, available: {}", Utils.formatsToString(track.getFileList())); throw new FeederException(); @@ -145,13 +146,13 @@ private LoadedStream loadTrack(@NotNull Metadata.Track track, @NotNull AudioQual } @NotNull - private LoadedStream loadEpisode(@NotNull EpisodeId id, @NotNull AudioQualityPreference audioQualityPreference, boolean preload, @Nullable HaltListener haltListener) throws IOException, MercuryClient.MercuryException, CdnManager.CdnException { + private LoadedStream loadEpisode(@NotNull EpisodeId id, @NotNull AudioQualityPicker audioQualityPicker, boolean preload, @Nullable HaltListener haltListener) throws IOException, MercuryClient.MercuryException, CdnManager.CdnException { Metadata.Episode episode = session.api().getMetadata4Episode(id); if (episode.hasExternalUrl()) { return CdnFeedHelper.loadEpisodeExternal(session, episode, haltListener); } else { - Metadata.AudioFile file = audioQualityPreference.getFile(episode.getAudioList()); + Metadata.AudioFile file = audioQualityPicker.getFile(episode.getAudioList()); if (file == null) { LOGGER.fatal("Couldn't find any suitable audio file, available: {}", Utils.formatsToString(episode.getAudioList())); throw new FeederException(); @@ -204,4 +205,61 @@ public static class FeederException extends IOException { FeederException() { } } + + public static class ContentRestrictedException extends Exception { + + public static void checkRestrictions(@NotNull String country, @NotNull List restrictions) throws ContentRestrictedException { + for (Metadata.Restriction restriction : restrictions) + if (isRestricted(country, restriction)) + throw new ContentRestrictedException(); + } + + private static boolean isInList(@NotNull String list, @NotNull String match) { + for (int i = 0; i < list.length(); i += 2) + if (list.substring(i, i + 2).equals(match)) + return true; + + return false; + } + + private static boolean isRestricted(@NotNull String countryCode, @NotNull Metadata.Restriction restriction) { + if (restriction.hasCountriesAllowed()) { + String allowed = restriction.getCountriesAllowed(); + if (allowed.isEmpty()) return true; + + if (!isInList(restriction.getCountriesForbidden(), countryCode)) + return true; + } + + if (restriction.hasCountriesForbidden()) + return isInList(restriction.getCountriesForbidden(), countryCode); + + return false; + } + } + + /** + * Event structure for fetching a file ID for some content. + * + * @author devgianlu + */ + private static final class FetchedFileIdEvent implements EventService.GenericEvent { + private final PlayableId content; + private final ByteString fileId; + + FetchedFileIdEvent(@NotNull PlayableId content, @NotNull ByteString fileId) { + this.content = content; + this.fileId = fileId; + } + + @Override + public EventService.@NotNull EventBuilder build() { + EventService.EventBuilder event = new EventService.EventBuilder(EventService.Type.FETCHED_FILE_ID); + event.append('2').append('2'); + event.append(Utils.bytesToHex(fileId).toLowerCase()); + event.append(content.toSpotifyUri()); + event.append('1').append('2').append('2'); + return event; + } + } } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/StreamId.java b/lib/src/main/java/xyz/gianlu/librespot/audio/StreamId.java similarity index 95% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/StreamId.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/StreamId.java index 9cfcccfd..9fe2a2ce 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/StreamId.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/StreamId.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.feeders; +package xyz.gianlu.librespot.audio; import com.google.protobuf.ByteString; import com.spotify.metadata.Metadata; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnFeedHelper.java b/lib/src/main/java/xyz/gianlu/librespot/audio/cdn/CdnFeedHelper.java similarity index 93% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnFeedHelper.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/cdn/CdnFeedHelper.java index 69fb7ec1..133140c0 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnFeedHelper.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/cdn/CdnFeedHelper.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.feeders.cdn; +package xyz.gianlu.librespot.audio.cdn; import com.spotify.metadata.Metadata; import com.spotify.storage.StorageResolve.StorageResolveResponse; @@ -9,12 +9,12 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.audio.HaltListener; +import xyz.gianlu.librespot.audio.NormalizationData; +import xyz.gianlu.librespot.audio.PlayableContentFeeder; +import xyz.gianlu.librespot.audio.PlayableContentFeeder.LoadedStream; import xyz.gianlu.librespot.common.Utils; import xyz.gianlu.librespot.core.Session; -import xyz.gianlu.librespot.player.codecs.NormalizationData; -import xyz.gianlu.librespot.player.feeders.HaltListener; -import xyz.gianlu.librespot.player.feeders.PlayableContentFeeder; -import xyz.gianlu.librespot.player.feeders.PlayableContentFeeder.LoadedStream; import java.io.IOException; import java.io.InputStream; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnManager.java b/lib/src/main/java/xyz/gianlu/librespot/audio/cdn/CdnManager.java similarity index 95% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnManager.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/cdn/CdnManager.java index 22dac44b..141834bf 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/cdn/CdnManager.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/cdn/CdnManager.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.feeders.cdn; +package xyz.gianlu.librespot.audio.cdn; import com.google.protobuf.ByteString; import com.spotify.metadata.Metadata; @@ -8,18 +8,17 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.audio.*; +import xyz.gianlu.librespot.audio.decrypt.AesAudioDecrypt; +import xyz.gianlu.librespot.audio.decrypt.AudioDecrypt; +import xyz.gianlu.librespot.audio.decrypt.NoopAudioDecrypt; +import xyz.gianlu.librespot.audio.format.SuperAudioFormat; +import xyz.gianlu.librespot.audio.storage.AudioFileFetch; import xyz.gianlu.librespot.cache.CacheManager; import xyz.gianlu.librespot.common.NameThreadFactory; import xyz.gianlu.librespot.common.Utils; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; -import xyz.gianlu.librespot.player.Player; -import xyz.gianlu.librespot.player.codecs.SuperAudioFormat; -import xyz.gianlu.librespot.player.decrypt.AesAudioDecrypt; -import xyz.gianlu.librespot.player.decrypt.AudioDecrypt; -import xyz.gianlu.librespot.player.decrypt.NoopAudioDecrypt; -import xyz.gianlu.librespot.player.feeders.*; -import xyz.gianlu.librespot.player.feeders.storage.AudioFileFetch; import java.io.IOException; import java.io.InputStream; @@ -28,7 +27,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; -import static xyz.gianlu.librespot.player.feeders.storage.ChannelManager.CHUNK_SIZE; +import static xyz.gianlu.librespot.audio.storage.ChannelManager.CHUNK_SIZE; /** * @author Gianlu @@ -245,7 +244,7 @@ private Streamer(@NotNull StreamId streamId, @NotNull SuperAudioFormat format, @ buffer = new byte[chunks][CHUNK_SIZE]; buffer[chunks - 1] = new byte[size % CHUNK_SIZE]; - this.internalStream = new InternalStream(session.conf()); + this.internalStream = new InternalStream(session.configuration().retryOnChunkError); writeChunk(firstChunk, 0, fromCache); } @@ -337,8 +336,8 @@ public int size() { private class InternalStream extends AbsChunkedInputStream { - private InternalStream(Player.@NotNull Configuration conf) { - super(conf); + private InternalStream(boolean retryOnChunkError) { + super(retryOnChunkError); } @Override diff --git a/core/src/main/java/xyz/gianlu/librespot/player/decrypt/AesAudioDecrypt.java b/lib/src/main/java/xyz/gianlu/librespot/audio/decrypt/AesAudioDecrypt.java similarity index 90% rename from core/src/main/java/xyz/gianlu/librespot/player/decrypt/AesAudioDecrypt.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/decrypt/AesAudioDecrypt.java index 65772bac..b11eefc5 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/decrypt/AesAudioDecrypt.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/decrypt/AesAudioDecrypt.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.decrypt; +package xyz.gianlu.librespot.audio.decrypt; import xyz.gianlu.librespot.common.Utils; @@ -11,7 +11,7 @@ import java.security.GeneralSecurityException; import java.security.NoSuchAlgorithmException; -import static xyz.gianlu.librespot.player.feeders.storage.ChannelManager.CHUNK_SIZE; +import static xyz.gianlu.librespot.audio.storage.ChannelManager.CHUNK_SIZE; /** * @author Gianlu @@ -56,7 +56,7 @@ public synchronized void decryptChunk(int chunkIndex, byte[] in, byte[] out) thr } /** - * Average decrypt time for {@link xyz.gianlu.librespot.player.feeders.storage.ChannelManager#CHUNK_SIZE} bytes of data. + * Average decrypt time for {@link xyz.gianlu.librespot.audio.storage.ChannelManager#CHUNK_SIZE} bytes of data. * * @return The average decrypt time in milliseconds */ diff --git a/core/src/main/java/xyz/gianlu/librespot/player/decrypt/AudioDecrypt.java b/lib/src/main/java/xyz/gianlu/librespot/audio/decrypt/AudioDecrypt.java similarity index 81% rename from core/src/main/java/xyz/gianlu/librespot/player/decrypt/AudioDecrypt.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/decrypt/AudioDecrypt.java index 20d66109..914de535 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/decrypt/AudioDecrypt.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/decrypt/AudioDecrypt.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.decrypt; +package xyz.gianlu.librespot.audio.decrypt; import java.io.IOException; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/decrypt/NoopAudioDecrypt.java b/lib/src/main/java/xyz/gianlu/librespot/audio/decrypt/NoopAudioDecrypt.java similarity index 94% rename from core/src/main/java/xyz/gianlu/librespot/player/decrypt/NoopAudioDecrypt.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/decrypt/NoopAudioDecrypt.java index e60b1af5..80e65415 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/decrypt/NoopAudioDecrypt.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/decrypt/NoopAudioDecrypt.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.decrypt; +package xyz.gianlu.librespot.audio.decrypt; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/codecs/AudioQualityPreference.java b/lib/src/main/java/xyz/gianlu/librespot/audio/format/AudioQualityPicker.java similarity index 75% rename from core/src/main/java/xyz/gianlu/librespot/player/codecs/AudioQualityPreference.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/format/AudioQualityPicker.java index e3201c8b..2b85f302 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/codecs/AudioQualityPreference.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/format/AudioQualityPicker.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.codecs; +package xyz.gianlu.librespot.audio.format; import com.spotify.metadata.Metadata; import org.jetbrains.annotations.NotNull; @@ -9,7 +9,7 @@ /** * @author Gianlu */ -public interface AudioQualityPreference { +public interface AudioQualityPicker { @Nullable Metadata.AudioFile getFile(@NotNull List files); diff --git a/core/src/main/java/xyz/gianlu/librespot/player/codecs/SuperAudioFormat.java b/lib/src/main/java/xyz/gianlu/librespot/audio/format/SuperAudioFormat.java similarity index 94% rename from core/src/main/java/xyz/gianlu/librespot/player/codecs/SuperAudioFormat.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/format/SuperAudioFormat.java index 0f540618..740183ff 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/codecs/SuperAudioFormat.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/format/SuperAudioFormat.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.codecs; +package xyz.gianlu.librespot.audio.format; import com.spotify.metadata.Metadata; import org.jetbrains.annotations.NotNull; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFile.java b/lib/src/main/java/xyz/gianlu/librespot/audio/storage/AudioFile.java similarity index 75% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFile.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/storage/AudioFile.java index e720e45d..a4358cd5 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFile.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/storage/AudioFile.java @@ -1,6 +1,6 @@ -package xyz.gianlu.librespot.player.feeders.storage; +package xyz.gianlu.librespot.audio.storage; -import xyz.gianlu.librespot.player.feeders.GeneralWritableStream; +import xyz.gianlu.librespot.audio.GeneralWritableStream; import java.io.Closeable; import java.io.IOException; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileFetch.java b/lib/src/main/java/xyz/gianlu/librespot/audio/storage/AudioFileFetch.java similarity index 90% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileFetch.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/storage/AudioFileFetch.java index a4fef97c..de90650e 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileFetch.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/storage/AudioFileFetch.java @@ -1,17 +1,17 @@ -package xyz.gianlu.librespot.player.feeders.storage; +package xyz.gianlu.librespot.audio.storage; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.audio.AbsChunkedInputStream; import xyz.gianlu.librespot.cache.CacheManager; import xyz.gianlu.librespot.common.Utils; -import xyz.gianlu.librespot.player.feeders.AbsChunkedInputStream; import java.io.IOException; import java.nio.ByteBuffer; -import static xyz.gianlu.librespot.player.feeders.storage.ChannelManager.CHUNK_SIZE; +import static xyz.gianlu.librespot.audio.storage.ChannelManager.CHUNK_SIZE; /** * @author Gianlu @@ -45,7 +45,8 @@ public synchronized void writeHeader(int id, byte[] bytes, boolean cached) throw cache.setHeader(id, bytes); } catch (IOException ex) { if (id == HEADER_SIZE) throw new IOException(ex); - else LOGGER.warn("Failed writing header to cache! {id: {}}", Utils.byteToHex((byte) id)); + else + LOGGER.warn("Failed writing header to cache! {id: {}}", Utils.byteToHex((byte) id)); } } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileStreaming.java b/lib/src/main/java/xyz/gianlu/librespot/audio/storage/AudioFileStreaming.java similarity index 92% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileStreaming.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/storage/AudioFileStreaming.java index 9236b3c5..49488d83 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/AudioFileStreaming.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/storage/AudioFileStreaming.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.feeders.storage; +package xyz.gianlu.librespot.audio.storage; import com.google.protobuf.ByteString; import com.spotify.metadata.Metadata; @@ -6,18 +6,17 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.audio.AbsChunkedInputStream; +import xyz.gianlu.librespot.audio.GeneralAudioStream; +import xyz.gianlu.librespot.audio.HaltListener; +import xyz.gianlu.librespot.audio.decrypt.AesAudioDecrypt; +import xyz.gianlu.librespot.audio.decrypt.AudioDecrypt; +import xyz.gianlu.librespot.audio.format.SuperAudioFormat; import xyz.gianlu.librespot.cache.CacheManager; import xyz.gianlu.librespot.cache.JournalHeader; import xyz.gianlu.librespot.common.NameThreadFactory; import xyz.gianlu.librespot.common.Utils; import xyz.gianlu.librespot.core.Session; -import xyz.gianlu.librespot.player.Player; -import xyz.gianlu.librespot.player.codecs.SuperAudioFormat; -import xyz.gianlu.librespot.player.decrypt.AesAudioDecrypt; -import xyz.gianlu.librespot.player.decrypt.AudioDecrypt; -import xyz.gianlu.librespot.player.feeders.AbsChunkedInputStream; -import xyz.gianlu.librespot.player.feeders.GeneralAudioStream; -import xyz.gianlu.librespot.player.feeders.HaltListener; import java.io.Closeable; import java.io.IOException; @@ -25,7 +24,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import static xyz.gianlu.librespot.player.feeders.storage.ChannelManager.CHUNK_SIZE; +import static xyz.gianlu.librespot.audio.storage.ChannelManager.CHUNK_SIZE; /** * @author Gianlu @@ -176,7 +175,7 @@ private class ChunksBuffer implements Closeable { this.available = new boolean[chunks]; this.requested = new boolean[chunks]; this.audioDecrypt = new AesAudioDecrypt(key); - this.internalStream = new InternalStream(session.conf()); + this.internalStream = new InternalStream(session.configuration().retryOnChunkError); } void writeChunk(@NotNull byte[] chunk, int chunkIndex) throws IOException { @@ -201,8 +200,8 @@ public void close() { private class InternalStream extends AbsChunkedInputStream { - private InternalStream(Player.@NotNull Configuration conf) { - super(conf); + private InternalStream(boolean retryOnChunkError) { + super(retryOnChunkError); } @Override diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/ChannelManager.java b/lib/src/main/java/xyz/gianlu/librespot/audio/storage/ChannelManager.java similarity index 99% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/ChannelManager.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/storage/ChannelManager.java index 80f831f2..d63ae8e1 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/ChannelManager.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/storage/ChannelManager.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.player.feeders.storage; +package xyz.gianlu.librespot.audio.storage; import com.google.protobuf.ByteString; import org.apache.logging.log4j.LogManager; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/StorageFeedHelper.java b/lib/src/main/java/xyz/gianlu/librespot/audio/storage/StorageFeedHelper.java similarity index 90% rename from core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/StorageFeedHelper.java rename to lib/src/main/java/xyz/gianlu/librespot/audio/storage/StorageFeedHelper.java index 0c134dae..7485b643 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/feeders/storage/StorageFeedHelper.java +++ b/lib/src/main/java/xyz/gianlu/librespot/audio/storage/StorageFeedHelper.java @@ -1,13 +1,13 @@ -package xyz.gianlu.librespot.player.feeders.storage; +package xyz.gianlu.librespot.audio.storage; import com.spotify.metadata.Metadata; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.audio.HaltListener; +import xyz.gianlu.librespot.audio.NormalizationData; +import xyz.gianlu.librespot.audio.PlayableContentFeeder; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.crypto.Packet; -import xyz.gianlu.librespot.player.codecs.NormalizationData; -import xyz.gianlu.librespot.player.feeders.HaltListener; -import xyz.gianlu.librespot.player.feeders.PlayableContentFeeder; import java.io.IOException; import java.io.InputStream; diff --git a/core/src/main/java/xyz/gianlu/librespot/cache/CacheJournal.java b/lib/src/main/java/xyz/gianlu/librespot/cache/CacheJournal.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/cache/CacheJournal.java rename to lib/src/main/java/xyz/gianlu/librespot/cache/CacheJournal.java diff --git a/core/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java b/lib/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java similarity index 93% rename from core/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java rename to lib/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java index 58e01760..776426bd 100644 --- a/core/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java +++ b/lib/src/main/java/xyz/gianlu/librespot/cache/CacheManager.java @@ -4,8 +4,9 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.player.feeders.GeneralWritableStream; -import xyz.gianlu.librespot.player.feeders.StreamId; +import xyz.gianlu.librespot.audio.GeneralWritableStream; +import xyz.gianlu.librespot.audio.StreamId; +import xyz.gianlu.librespot.core.Session; import java.io.Closeable; import java.io.File; @@ -19,7 +20,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; -import static xyz.gianlu.librespot.player.feeders.storage.ChannelManager.CHUNK_SIZE; +import static xyz.gianlu.librespot.audio.storage.ChannelManager.CHUNK_SIZE; /** @@ -33,14 +34,14 @@ public class CacheManager implements Closeable { private final CacheJournal journal; private final Map fileHandlers = new ConcurrentHashMap<>(); - public CacheManager(@NotNull Configuration conf) throws IOException { - if (!conf.cacheEnabled()) { + public CacheManager(@NotNull Session.Configuration conf) throws IOException { + if (!conf.cacheEnabled) { parent = null; journal = null; return; } - this.parent = conf.cacheDir(); + this.parent = conf.cacheDir; if (!parent.exists() && !parent.mkdir()) throw new IOException("Couldn't create cache directory!"); @@ -58,7 +59,7 @@ public CacheManager(@NotNull Configuration conf) throws IOException { } } - if (conf.doCleanUp()) { + if (conf.doCacheCleanUp) { for (String id : entries) { JournalHeader header = journal.getHeader(id, HEADER_TIMESTAMP); if (header == null) continue; @@ -126,14 +127,6 @@ public Handler getHandler(@NotNull StreamId streamId) throws IOException { return getHandler(streamId.isEpisode() ? streamId.getEpisodeGid() : streamId.getFileId()); } - public interface Configuration { - boolean cacheEnabled(); - - @NotNull File cacheDir(); - - boolean doCleanUp(); - } - public class Handler implements Closeable { private final String streamId; private final RandomAccessFile io; diff --git a/core/src/main/java/xyz/gianlu/librespot/cache/JournalHeader.java b/lib/src/main/java/xyz/gianlu/librespot/cache/JournalHeader.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/cache/JournalHeader.java rename to lib/src/main/java/xyz/gianlu/librespot/cache/JournalHeader.java diff --git a/common/src/main/java/xyz/gianlu/librespot/common/AsyncProcessor.java b/lib/src/main/java/xyz/gianlu/librespot/common/AsyncProcessor.java similarity index 100% rename from common/src/main/java/xyz/gianlu/librespot/common/AsyncProcessor.java rename to lib/src/main/java/xyz/gianlu/librespot/common/AsyncProcessor.java diff --git a/common/src/main/java/xyz/gianlu/librespot/common/AsyncWorker.java b/lib/src/main/java/xyz/gianlu/librespot/common/AsyncWorker.java similarity index 100% rename from common/src/main/java/xyz/gianlu/librespot/common/AsyncWorker.java rename to lib/src/main/java/xyz/gianlu/librespot/common/AsyncWorker.java diff --git a/common/src/main/java/xyz/gianlu/librespot/common/Base62.java b/lib/src/main/java/xyz/gianlu/librespot/common/Base62.java similarity index 100% rename from common/src/main/java/xyz/gianlu/librespot/common/Base62.java rename to lib/src/main/java/xyz/gianlu/librespot/common/Base62.java diff --git a/core/src/main/java/xyz/gianlu/librespot/BytesArrayList.java b/lib/src/main/java/xyz/gianlu/librespot/common/BytesArrayList.java similarity index 98% rename from core/src/main/java/xyz/gianlu/librespot/BytesArrayList.java rename to lib/src/main/java/xyz/gianlu/librespot/common/BytesArrayList.java index 9e3ef67f..c3b9878b 100644 --- a/core/src/main/java/xyz/gianlu/librespot/BytesArrayList.java +++ b/lib/src/main/java/xyz/gianlu/librespot/common/BytesArrayList.java @@ -1,7 +1,6 @@ -package xyz.gianlu.librespot; +package xyz.gianlu.librespot.common; import org.jetbrains.annotations.NotNull; -import xyz.gianlu.librespot.common.Utils; import java.io.InputStream; import java.util.Arrays; diff --git a/common/src/main/java/xyz/gianlu/librespot/common/FisherYatesShuffle.java b/lib/src/main/java/xyz/gianlu/librespot/common/FisherYatesShuffle.java similarity index 100% rename from common/src/main/java/xyz/gianlu/librespot/common/FisherYatesShuffle.java rename to lib/src/main/java/xyz/gianlu/librespot/common/FisherYatesShuffle.java diff --git a/common/src/main/java/xyz/gianlu/librespot/common/Log4JUncaughtExceptionHandler.java b/lib/src/main/java/xyz/gianlu/librespot/common/Log4JUncaughtExceptionHandler.java similarity index 100% rename from common/src/main/java/xyz/gianlu/librespot/common/Log4JUncaughtExceptionHandler.java rename to lib/src/main/java/xyz/gianlu/librespot/common/Log4JUncaughtExceptionHandler.java diff --git a/common/src/main/java/xyz/gianlu/librespot/common/NameThreadFactory.java b/lib/src/main/java/xyz/gianlu/librespot/common/NameThreadFactory.java similarity index 100% rename from common/src/main/java/xyz/gianlu/librespot/common/NameThreadFactory.java rename to lib/src/main/java/xyz/gianlu/librespot/common/NameThreadFactory.java diff --git a/common/src/main/java/xyz/gianlu/librespot/common/NetUtils.java b/lib/src/main/java/xyz/gianlu/librespot/common/NetUtils.java similarity index 100% rename from common/src/main/java/xyz/gianlu/librespot/common/NetUtils.java rename to lib/src/main/java/xyz/gianlu/librespot/common/NetUtils.java diff --git a/common/src/main/java/xyz/gianlu/librespot/common/ProtoUtils.java b/lib/src/main/java/xyz/gianlu/librespot/common/ProtoUtils.java similarity index 100% rename from common/src/main/java/xyz/gianlu/librespot/common/ProtoUtils.java rename to lib/src/main/java/xyz/gianlu/librespot/common/ProtoUtils.java diff --git a/common/src/main/java/xyz/gianlu/librespot/common/ProtobufToJson.java b/lib/src/main/java/xyz/gianlu/librespot/common/ProtobufToJson.java similarity index 81% rename from common/src/main/java/xyz/gianlu/librespot/common/ProtobufToJson.java rename to lib/src/main/java/xyz/gianlu/librespot/common/ProtobufToJson.java index 358f4fec..59018e49 100644 --- a/common/src/main/java/xyz/gianlu/librespot/common/ProtobufToJson.java +++ b/lib/src/main/java/xyz/gianlu/librespot/common/ProtobufToJson.java @@ -35,44 +35,44 @@ public static JsonArray convertList(@NotNull List list) { return array; } - private static JsonArray arrayOfNumbers(List list) { + private static @NotNull JsonArray arrayOfNumbers(@NotNull List list) { JsonArray array = new JsonArray(list.size()); for (Number num : list) array.add(num); return array; } - private static JsonArray arrayOfBooleans(List list) { + private static @NotNull JsonArray arrayOfBooleans(@NotNull List list) { JsonArray array = new JsonArray(list.size()); for (Boolean b : list) array.add(b); return array; } - private static JsonObject mapOfStrings(List map) { + private static @NotNull JsonObject mapOfStrings(@NotNull List> map) { JsonObject obj = new JsonObject(); - for (MapEntry entry : map) obj.addProperty(entry.getKey().toString(), entry.getValue().toString()); + for (MapEntry entry : map) obj.addProperty(entry.getKey().toString(), entry.getValue().toString()); return obj; } - private static JsonArray arrayOfStrings(List list) { + private static @NotNull JsonArray arrayOfStrings(@NotNull List list) { JsonArray array = new JsonArray(list.size()); for (String str : list) array.add(str); return array; } - private static JsonArray arrayOfEnums(List list) { + private static @NotNull JsonArray arrayOfEnums(@NotNull List list) { JsonArray array = new JsonArray(list.size()); for (Descriptors.EnumValueDescriptor desc : list) array.add(desc.getName()); return array; } - private static JsonArray arrayOfByteStrings(List list) { + private static @NotNull JsonArray arrayOfByteStrings(@NotNull List list) { JsonArray array = new JsonArray(list.size()); for (ByteString str : list) array.add(Utils.bytesToHex(str)); return array; } @SuppressWarnings("unchecked") - private static void put(JsonObject json, Descriptors.FieldDescriptor descriptor, Object obj) { + private static void put(@NotNull JsonObject json, @NotNull Descriptors.FieldDescriptor descriptor, Object obj) { String key = descriptor.getJsonName(); switch (descriptor.getJavaType()) { case FLOAT: @@ -99,7 +99,7 @@ private static void put(JsonObject json, Descriptors.FieldDescriptor descriptor, else json.addProperty(key, ((Descriptors.EnumValueDescriptor) obj).getName()); break; case MESSAGE: - if (descriptor.isMapField()) json.add(key, mapOfStrings((List) obj)); + if (descriptor.isMapField()) json.add(key, mapOfStrings((List>) obj)); else if (descriptor.isRepeated()) json.add(key, convertList((List) obj)); else json.add(key, convert((Message) obj)); break; diff --git a/common/src/main/java/xyz/gianlu/librespot/common/Utils.java b/lib/src/main/java/xyz/gianlu/librespot/common/Utils.java similarity index 100% rename from common/src/main/java/xyz/gianlu/librespot/common/Utils.java rename to lib/src/main/java/xyz/gianlu/librespot/common/Utils.java diff --git a/core/src/main/java/xyz/gianlu/librespot/core/ApResolver.java b/lib/src/main/java/xyz/gianlu/librespot/core/ApResolver.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/core/ApResolver.java rename to lib/src/main/java/xyz/gianlu/librespot/core/ApResolver.java diff --git a/lib/src/main/java/xyz/gianlu/librespot/core/EventService.java b/lib/src/main/java/xyz/gianlu/librespot/core/EventService.java new file mode 100644 index 00000000..29a17119 --- /dev/null +++ b/lib/src/main/java/xyz/gianlu/librespot/core/EventService.java @@ -0,0 +1,143 @@ +package xyz.gianlu.librespot.core; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.common.AsyncWorker; +import xyz.gianlu.librespot.mercury.MercuryClient; +import xyz.gianlu.librespot.mercury.RawMercuryRequest; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +/** + * @author Gianlu + */ +public final class EventService implements Closeable { + private final static Logger LOGGER = LogManager.getLogger(EventService.class); + private final AsyncWorker asyncWorker; + + EventService(@NotNull Session session) { + this.asyncWorker = new AsyncWorker<>("event-service-sender", eventBuilder -> { + try { + byte[] body = eventBuilder.toArray(); + MercuryClient.Response resp = session.mercury().sendSync(RawMercuryRequest.newBuilder() + .setUri("hm://event-service/v1/events").setMethod("POST") + .addUserField("Accept-Language", "en") + .addUserField("X-ClientTimeStamp", String.valueOf(TimeProvider.currentTimeMillis())) + .addPayloadPart(body) + .build()); + + LOGGER.debug("Event sent. {body: {}, result: {}}", EventBuilder.toString(body), resp.statusCode); + } catch (IOException ex) { + LOGGER.error("Failed sending event: " + eventBuilder, ex); + } + }); + } + + public void sendEvent(@NotNull GenericEvent event) { + sendEvent(event.build()); + } + + public void sendEvent(@NotNull EventBuilder builder) { + asyncWorker.submit(builder); + } + + /** + * Reports our language. + * + * @param lang The language (2 letters code) + */ + public void language(@NotNull String lang) { + EventBuilder event = new EventBuilder(Type.LANGUAGE); + event.append(lang); + sendEvent(event); + } + + @Override + public void close() { + asyncWorker.close(); + + try { + asyncWorker.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + } + } + + public enum Type { + LANGUAGE("812", "1"), FETCHED_FILE_ID("274", "3"), NEW_SESSION_ID("557", "3"), + NEW_PLAYBACK_ID("558", "1"), TRACK_PLAYED("372", "1"), TRACK_TRANSITION("12", "37"), + CDN_REQUEST("10", "20"); + + private final String id; + private final String unknown; + + Type(@NotNull String id, @NotNull String unknown) { + this.id = id; + this.unknown = unknown; + } + } + + public interface GenericEvent { + @NotNull + EventBuilder build(); + } + + public static class EventBuilder { + private final ByteArrayOutputStream body = new ByteArrayOutputStream(256); + + public EventBuilder(@NotNull Type type) { + appendNoDelimiter(type.id); + append(type.unknown); + } + + @NotNull + static String toString(@NotNull byte[] body) { + StringBuilder result = new StringBuilder(); + for (byte b : body) { + if (b == 0x09) result.append('|'); + else result.append((char) b); + } + + return result.toString(); + } + + private void appendNoDelimiter(@Nullable String str) { + if (str == null) str = ""; + + try { + body.write(str.getBytes(StandardCharsets.UTF_8)); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + } + + @NotNull + public EventBuilder append(char c) { + body.write(0x09); + body.write(c); + return this; + } + + @NotNull + public EventBuilder append(@Nullable String str) { + body.write(0x09); + appendNoDelimiter(str); + return this; + } + + @Override + public String toString() { + return "EventBuilder{" + toString(toArray()) + '}'; + } + + @NotNull + byte[] toArray() { + return body.toByteArray(); + } + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/core/FacebookAuthenticator.java b/lib/src/main/java/xyz/gianlu/librespot/core/FacebookAuthenticator.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/core/FacebookAuthenticator.java rename to lib/src/main/java/xyz/gianlu/librespot/core/FacebookAuthenticator.java diff --git a/core/src/main/java/xyz/gianlu/librespot/core/PacketsManager.java b/lib/src/main/java/xyz/gianlu/librespot/core/PacketsManager.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/core/PacketsManager.java rename to lib/src/main/java/xyz/gianlu/librespot/core/PacketsManager.java diff --git a/core/src/main/java/xyz/gianlu/librespot/core/SearchManager.java b/lib/src/main/java/xyz/gianlu/librespot/core/SearchManager.java similarity index 97% rename from core/src/main/java/xyz/gianlu/librespot/core/SearchManager.java rename to lib/src/main/java/xyz/gianlu/librespot/core/SearchManager.java index 28352332..7dc2d729 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/SearchManager.java +++ b/lib/src/main/java/xyz/gianlu/librespot/core/SearchManager.java @@ -27,7 +27,7 @@ public SearchManager(@NotNull Session session) { public JsonObject request(@NotNull SearchRequest req) throws IOException { if (req.username.isEmpty()) req.username = session.username(); if (req.country.isEmpty()) req.country = session.countryCode(); - if (req.locale.isEmpty()) req.locale = session.conf().preferredLocale(); + if (req.locale.isEmpty()) req.locale = session.preferredLocale(); MercuryClient.Response resp = session.mercury().sendSync(RawMercuryRequest.newBuilder() .setMethod("GET").setUri(req.buildUrl()).build()); diff --git a/core/src/main/java/xyz/gianlu/librespot/core/Session.java b/lib/src/main/java/xyz/gianlu/librespot/core/Session.java similarity index 74% rename from core/src/main/java/xyz/gianlu/librespot/core/Session.java rename to lib/src/main/java/xyz/gianlu/librespot/core/Session.java index a73d64cf..268d8a76 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/Session.java +++ b/lib/src/main/java/xyz/gianlu/librespot/core/Session.java @@ -22,8 +22,11 @@ import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.SAXException; -import xyz.gianlu.librespot.AbsConfiguration; import xyz.gianlu.librespot.Version; +import xyz.gianlu.librespot.audio.AudioKeyManager; +import xyz.gianlu.librespot.audio.PlayableContentFeeder; +import xyz.gianlu.librespot.audio.cdn.CdnManager; +import xyz.gianlu.librespot.audio.storage.ChannelManager; import xyz.gianlu.librespot.cache.CacheManager; import xyz.gianlu.librespot.common.NameThreadFactory; import xyz.gianlu.librespot.common.Utils; @@ -35,11 +38,6 @@ import xyz.gianlu.librespot.dealer.DealerClient; import xyz.gianlu.librespot.mercury.MercuryClient; import xyz.gianlu.librespot.mercury.SubListener; -import xyz.gianlu.librespot.player.AudioKeyManager; -import xyz.gianlu.librespot.player.Player; -import xyz.gianlu.librespot.player.feeders.PlayableContentFeeder; -import xyz.gianlu.librespot.player.feeders.cdn.CdnManager; -import xyz.gianlu.librespot.player.feeders.storage.ChannelManager; import javax.crypto.Cipher; import javax.crypto.Mac; @@ -100,7 +98,6 @@ public final class Session implements Closeable, SubListener, DealerClient.Messa private Receiver receiver; private Authentication.APWelcome apWelcome = null; private MercuryClient mercuryClient; - private Player player; private AudioKeyManager audioKeyManager; private ChannelManager channelManager; private TokenProvider tokenProvider; @@ -116,26 +113,26 @@ public final class Session implements Closeable, SubListener, DealerClient.Messa private volatile boolean closing = false; private volatile ScheduledFuture scheduledReconnect = null; - private Session(Inner inner, String addr) throws IOException { + private Session(@NotNull Inner inner, @NotNull String addr) throws IOException { this.inner = inner; this.keys = new DiffieHellman(inner.random); - this.conn = ConnectionHolder.create(addr, inner.configuration); - this.client = createClient(inner.configuration); + this.conn = ConnectionHolder.create(addr, inner.conf); + this.client = createClient(inner.conf); - LOGGER.info("Created new session! {deviceId: {}, ap: {}, proxy: {}} ", inner.deviceId, addr, inner.configuration.proxyEnabled()); + LOGGER.info("Created new session! {deviceId: {}, ap: {}, proxy: {}} ", inner.deviceId, addr, inner.conf.proxyEnabled); } @NotNull - private static OkHttpClient createClient(@NotNull ProxyConfiguration conf) { + private static OkHttpClient createClient(@NotNull Configuration conf) { OkHttpClient.Builder builder = new OkHttpClient.Builder(); builder.retryOnConnectionFailure(true); - if (conf.proxyEnabled() && conf.proxyType() != Proxy.Type.DIRECT) { - builder.proxy(new Proxy(conf.proxyType(), new InetSocketAddress(conf.proxyAddress(), conf.proxyPort()))); - if (conf.proxyAuth()) { + if (conf.proxyEnabled && conf.proxyType != Proxy.Type.DIRECT) { + builder.proxy(new Proxy(conf.proxyType, new InetSocketAddress(conf.proxyAddress, conf.proxyPort))); + if (conf.proxyAuth) { builder.proxyAuthenticator(new Authenticator() { - final String username = conf.proxyUsername(); - final String password = conf.proxyPassword(); + final String username = conf.proxyUsername; + final String password = conf.proxyPassword; @Override public Request authenticate(Route route, @NotNull Response response) { @@ -188,19 +185,12 @@ private static int readBlobInt(ByteBuffer buffer) { return lo & 0x7f | hi << 7; } - @NotNull - static Session from(@NotNull Inner inner) throws IOException { - ApResolver.fillPool(); - TimeProvider.init(inner.configuration); - return new Session(inner, ApResolver.getRandomAccesspoint()); - } - @NotNull public OkHttpClient client() { return client; } - void connect() throws IOException, GeneralSecurityException, SpotifyAuthenticationException { + private void connect() throws IOException, GeneralSecurityException, SpotifyAuthenticationException { Accumulator acc = new Accumulator(); // Send ClientHello @@ -327,7 +317,7 @@ void connect() throws IOException, GeneralSecurityException, SpotifyAuthenticati * Authenticates with the server and creates all the necessary components. * All of them should be initialized inside the synchronized block and MUST NOT call any method on this {@link Session} object. */ - void authenticate(@NotNull Authentication.LoginCredentials credentials) throws IOException, GeneralSecurityException, SpotifyAuthenticationException, MercuryClient.MercuryException { + private void authenticate(@NotNull Authentication.LoginCredentials credentials) throws IOException, GeneralSecurityException, SpotifyAuthenticationException, MercuryClient.MercuryException { authenticatePartial(credentials, false); synchronized (authLock) { @@ -338,9 +328,8 @@ void authenticate(@NotNull Authentication.LoginCredentials credentials) throws I api = new ApiClient(this); cdnManager = new CdnManager(this); contentFeeder = new PlayableContentFeeder(this); - cacheManager = new CacheManager(conf()); + cacheManager = new CacheManager(inner.conf); dealer = new DealerClient(this); - player = new Player(conf(), this); search = new SearchManager(this); eventService = new EventService(this); @@ -348,9 +337,8 @@ void authenticate(@NotNull Authentication.LoginCredentials credentials) throws I authLock.notifyAll(); } - eventService.language(conf().preferredLocale()); + eventService.language(inner.preferredLocale); TimeProvider.init(this); - player.initState(); dealer.connect(); LOGGER.info("Authenticated as {}!", apWelcome.getCanonicalUsername()); @@ -395,7 +383,7 @@ private void authenticatePartial(@NotNull Authentication.LoginCredentials creden ByteBuffer preferredLocale = ByteBuffer.allocate(18 + 5); preferredLocale.put((byte) 0x0).put((byte) 0x0).put((byte) 0x10).put((byte) 0x0).put((byte) 0x02); preferredLocale.put("preferred-locale".getBytes()); - preferredLocale.put(conf().preferredLocale().getBytes()); + preferredLocale.put(inner.preferredLocale.getBytes()); sendUnchecked(Packet.Type.PreferredLocale, preferredLocale.array()); if (removeLock) { @@ -405,7 +393,7 @@ private void authenticatePartial(@NotNull Authentication.LoginCredentials creden } } - if (conf().authStrategy() != AuthConfiguration.Strategy.ZEROCONF && conf().storeCredentials()) { + if (inner.conf.storeCredentials) { ByteString reusable = apWelcome.getReusableAuthCredentials(); Authentication.AuthenticationType reusableType = apWelcome.getReusableAuthCredentialsType(); @@ -414,9 +402,8 @@ private void authenticatePartial(@NotNull Authentication.LoginCredentials creden obj.addProperty("credentials", Utils.toBase64(reusable)); obj.addProperty("type", reusableType.name()); - File storeFile = conf().credentialsFile(); - if (storeFile == null) throw new IllegalArgumentException(); - try (FileOutputStream out = new FileOutputStream(storeFile)) { + if (inner.conf.storedCredentialsFile == null) throw new IllegalArgumentException(); + try (FileOutputStream out = new FileOutputStream(inner.conf.storedCredentialsFile)) { out.write(obj.toString().getBytes()); } } @@ -435,11 +422,6 @@ public void close() throws IOException { scheduler.shutdownNow(); - if (player != null) { - player.close(); - player = null; - } - if (dealer != null) { dealer.close(); dealer = null; @@ -606,13 +588,6 @@ public PlayableContentFeeder contentFeeder() { return contentFeeder; } - @NotNull - public Player player() { - waitAuthLock(); - if (player == null) throw new IllegalStateException("Session isn't authenticated!"); - return player; - } - @NotNull public SearchManager search() { waitAuthLock(); @@ -639,10 +614,6 @@ public Authentication.APWelcome apWelcome() { return apWelcome; } - public boolean isActive() { - return player().isActive(); - } - public boolean isValid() { if (closed) return false; @@ -654,19 +625,29 @@ public boolean reconnecting() { return !closing && !closed && conn == null; } + @NotNull + ExecutorService executor() { + return executorService; + } + + @Nullable + public String countryCode() { + return countryCode; + } + @NotNull public String deviceId() { return inner.deviceId; } @NotNull - public Connect.DeviceType deviceType() { - return inner.deviceType; + public String preferredLocale() { + return inner.preferredLocale; } @NotNull - ExecutorService executor() { - return executorService; + public Connect.DeviceType deviceType() { + return inner.deviceType; } @NotNull @@ -679,6 +660,11 @@ public Random random() { return inner.random; } + @NotNull + public Configuration configuration() { + return inner.conf; + } + private void reconnect() { synchronized (reconnectionListeners) { reconnectionListeners.forEach(ReconnectionListener::onConnectionDropped); @@ -690,7 +676,7 @@ private void reconnect() { receiver.stop(); } - conn = ConnectionHolder.create(ApResolver.getRandomAccesspoint(), conf()); + conn = ConnectionHolder.create(ApResolver.getRandomAccesspoint(), inner.conf); connect(); authenticatePartial(Authentication.LoginCredentials.newBuilder() .setUsername(apWelcome.getCanonicalUsername()) @@ -715,16 +701,6 @@ private void reconnect() { } } - @NotNull - public AbsConfiguration conf() { - return inner.configuration; - } - - @Nullable - public String countryCode() { - return countryCode; - } - public void addCloseListener(@NotNull CloseListener listener) { if (!closeListeners.contains(listener)) closeListeners.add(listener); } @@ -785,7 +761,7 @@ public void event(@NotNull MercuryClient.Response resp) { } @Override - public void onMessage(@NotNull String uri, @NotNull Map headers, @NotNull byte[] payload) throws IOException { + public void onMessage(@NotNull String uri, @NotNull Map headers, @NotNull byte[] payload) { if (uri.equals("hm://connect-state/v1/connect/logout")) { try { close(); @@ -801,62 +777,105 @@ public interface ReconnectionListener { void onConnectionEstablished(); } - public interface ProxyConfiguration { - boolean proxyEnabled(); - - @NotNull - Proxy.Type proxyType(); - - @NotNull - String proxyAddress(); - - int proxyPort(); - - boolean proxyAuth(); - - @NotNull - String proxyUsername(); - - @NotNull - String proxyPassword(); - } - public interface CloseListener { void onClosed(); } - static class Inner { + private static class Inner { final Connect.DeviceType deviceType; final String deviceName; final SecureRandom random; final String deviceId; - final AbsConfiguration configuration; + final Configuration conf; + final String preferredLocale; - private Inner(Connect.DeviceType deviceType, String deviceName, AbsConfiguration configuration) { + private Inner(@NotNull Connect.DeviceType deviceType, @NotNull String deviceName, @Nullable String deviceId, @NotNull String preferredLocale, @NotNull Configuration conf) { + this.random = new SecureRandom(); + this.preferredLocale = preferredLocale; + this.conf = conf; this.deviceType = deviceType; this.deviceName = deviceName; - this.configuration = configuration; - this.random = new SecureRandom(); + this.deviceId = (deviceId == null || deviceId.isEmpty()) ? Utils.randomHexString(random, 40).toLowerCase() : deviceId; + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + public static abstract class AbsBuilder { + protected final Configuration conf; + protected String deviceId = null; + protected String deviceName = "librespot-java"; + protected Connect.DeviceType deviceType = Connect.DeviceType.COMPUTER; + protected String preferredLocale = "en"; - String configuredDeviceId = configuration.deviceId(); - this.deviceId = (configuredDeviceId == null || configuredDeviceId.isEmpty()) ? - Utils.randomHexString(random, 40).toLowerCase() : configuredDeviceId; + public AbsBuilder(@NotNull Configuration conf) { + this.conf = conf; } - @NotNull - static Inner from(@NotNull AbsConfiguration configuration) { - String deviceName = configuration.deviceName(); - if (deviceName == null || deviceName.isEmpty()) - throw new IllegalArgumentException("Device name required: " + deviceName); + public AbsBuilder() { + this(new Configuration.Builder().build()); + } - Connect.DeviceType deviceType = configuration.deviceType(); - if (deviceType == null) - throw new IllegalArgumentException("Device type required!"); + /** + * Sets the preferred locale for the user. + * + * @param locale A 2 chars locale code + */ + public T setPreferredLocale(@NotNull String locale) { + if (locale.length() != 2) + throw new IllegalArgumentException("Invalid locale: " + locale); - return new Inner(deviceType, deviceName, configuration); + this.preferredLocale = locale; + return (T) this; + } + + /** + * Sets the device name that will appear on Spotify Connect. + * + * @param deviceName The device name + */ + public T setDeviceName(@NotNull String deviceName) { + this.deviceName = deviceName; + return (T) this; } - @NotNull Authentication.LoginCredentials decryptBlob(String username, byte[] encryptedBlob) throws GeneralSecurityException, IOException { + /** + * Sets the device ID. If not provided or empty will be generated randomly. + * + * @param deviceId A 40 chars string + */ + public T setDeviceId(@Nullable String deviceId) { + if (deviceId != null && deviceId.length() != 40) + throw new IllegalArgumentException("Device ID must be 40 chars long."); + + this.deviceId = deviceId; + return (T) this; + } + + /** + * Sets the device type. + * + * @param deviceType The {@link com.spotify.connectstate.Connect.DeviceType} + */ + public T setDeviceType(@NotNull Connect.DeviceType deviceType) { + this.deviceType = deviceType; + return (T) this; + } + } + + /** + * Builder for setting up a {@link Session} object. + */ + public static class Builder extends AbsBuilder { + private Authentication.LoginCredentials loginCredentials = null; + + public Builder(@NotNull Configuration conf) { + super(conf); + } + + public Builder() { + } + + private static @NotNull Authentication.LoginCredentials decryptBlob(@NotNull String deviceId, @NotNull String username, byte[] encryptedBlob) throws GeneralSecurityException, IOException { encryptedBlob = Base64.getDecoder().decode(encryptedBlob); byte[] secret = MessageDigest.getInstance("SHA-1").digest(deviceId.getBytes()); @@ -898,53 +917,73 @@ static Inner from(@NotNull AbsConfiguration configuration) { .setAuthData(ByteString.copyFrom(authData)) .build(); } - } - /** - * Builder for setting up a {@link Session} object. - */ - public static class Builder { - private final Inner inner; - private final AuthConfiguration authConf; - private Authentication.LoginCredentials loginCredentials = null; + /** + * Gets the current credentials initialised for this {@link Builder}. + * + * @return A {@link com.spotify.Authentication.LoginCredentials} object or {@code null} + */ + @Nullable + public Authentication.LoginCredentials getCredentials() { + return loginCredentials; + } - public Builder(@NotNull Connect.DeviceType deviceType, @NotNull String deviceName, @NotNull AbsConfiguration configuration) { - this.inner = new Inner(deviceType, deviceName, configuration); - this.authConf = configuration; + /** + * Authenticates with stored credentials. Tries to read the file specified in the configuration. + */ + public Builder stored() throws IOException { + if (!conf.storeCredentials) throw new IllegalStateException("Credentials storing not enabled!"); + return stored(conf.storedCredentialsFile); } - public Builder(@NotNull AbsConfiguration configuration) { - this.inner = Inner.from(configuration); - this.authConf = configuration; + /** + * Authenticates with stored credentials. The file must exist and be readable. + * + * @param storedCredentials The file where the JSON credentials are stored + */ + public Builder stored(@NotNull File storedCredentials) throws IOException { + try (FileReader reader = new FileReader(storedCredentials)) { + JsonObject obj = JsonParser.parseReader(reader).getAsJsonObject(); + loginCredentials = Authentication.LoginCredentials.newBuilder() + .setTyp(Authentication.AuthenticationType.valueOf(obj.get("type").getAsString())) + .setUsername(obj.get("username").getAsString()) + .setAuthData(Utils.fromBase64(obj.get("credentials").getAsString())) + .build(); + } + + return this; } /** - * Authenticate with your Facebook account, will prompt to open a link in the browser. + * Authenticates with your Facebook account, will prompt to open a link in the browser. This locks until completion. */ @NotNull public Builder facebook() throws IOException { try (FacebookAuthenticator authenticator = new FacebookAuthenticator()) { loginCredentials = authenticator.lockUntilCredentials(); - return this; - } catch (InterruptedException ex) { - throw new IOException(ex); + } catch (InterruptedException ignored) { } + + return this; } /** - * Authenticate with a saved credentials blob. + * Authenticates with a saved credentials blob. * * @param username Your Spotify username * @param blob The Base64-decoded blob */ @NotNull - public Builder blob(String username, byte[] blob) throws GeneralSecurityException, IOException { - loginCredentials = inner.decryptBlob(username, blob); + public Builder blob(@NotNull String username, byte[] blob) throws GeneralSecurityException, IOException { + if (deviceId == null) + throw new IllegalStateException("You must specify the device ID first."); + + loginCredentials = decryptBlob(deviceId, username, blob); return this; } /** - * Authenticate with username and password. The credentials won't be saved. + * Authenticates with username and password. The credentials won't be saved. * * @param username Your Spotify username * @param password Your Spotify password @@ -964,55 +1003,184 @@ public Builder userPass(@NotNull String username, @NotNull String password) { */ @NotNull public Session create() throws IOException, GeneralSecurityException, SpotifyAuthenticationException, MercuryClient.MercuryException { - if (authConf.storeCredentials()) { - File storeFile = authConf.credentialsFile(); - if (storeFile != null && storeFile.exists()) { - try (FileReader reader = new FileReader(storeFile)) { - JsonObject obj = JsonParser.parseReader(reader).getAsJsonObject(); - loginCredentials = Authentication.LoginCredentials.newBuilder() - .setTyp(Authentication.AuthenticationType.valueOf(obj.get("type").getAsString())) - .setUsername(obj.get("username").getAsString()) - .setAuthData(Utils.fromBase64(obj.get("credentials").getAsString())) - .build(); - } - } - } - - if (loginCredentials == null) { - String blob = authConf.authBlob(); - String username = authConf.authUsername(); - String password = authConf.authPassword(); + if (loginCredentials == null) + throw new IllegalStateException("You must select an authentication method."); - switch (authConf.authStrategy()) { - case FACEBOOK: - facebook(); - break; - case BLOB: - if (username == null) throw new IllegalArgumentException("Missing authUsername!"); - if (blob == null) throw new IllegalArgumentException("Missing authBlob!"); - blob(username, Base64.getDecoder().decode(blob)); - break; - case USER_PASS: - if (username == null) throw new IllegalArgumentException("Missing authUsername!"); - if (password == null) throw new IllegalArgumentException("Missing authPassword!"); - userPass(username, password); - break; - case ZEROCONF: - throw new IllegalStateException("Cannot handle ZEROCONF! Use ZeroconfServer."); - default: - throw new IllegalStateException("Unknown auth authStrategy: " + authConf.authStrategy()); - } - } + ApResolver.fillPool(); + TimeProvider.init(conf); - Session session = Session.from(inner); + Session session = new Session(new Inner(deviceType, deviceName, deviceId, preferredLocale, conf), ApResolver.getRandomAccesspoint()); session.connect(); session.authenticate(loginCredentials); return session; } } + public final static class Configuration { + // Proxy + public final boolean proxyEnabled; + public final Proxy.Type proxyType; + public final String proxyAddress; + public final int proxyPort; + public final boolean proxyAuth; + public final String proxyUsername; + public final String proxyPassword; + + // Time sync + public final TimeProvider.Method timeSynchronizationMethod; + public final int timeManualCorrection; + + // Cache + public final boolean cacheEnabled; + public final File cacheDir; + public final boolean doCacheCleanUp; + + // Stored credentials + public final boolean storeCredentials; + public final File storedCredentialsFile; + + // Fetching + public final boolean retryOnChunkError; + + private Configuration(boolean proxyEnabled, Proxy.Type proxyType, String proxyAddress, int proxyPort, boolean proxyAuth, String proxyUsername, String proxyPassword, + TimeProvider.Method timeSynchronizationMethod, int timeManualCorrection, + boolean cacheEnabled, File cacheDir, boolean doCacheCleanUp, + boolean storeCredentials, File storedCredentialsFile, + boolean retryOnChunkError) { + this.proxyEnabled = proxyEnabled; + this.proxyType = proxyType; + this.proxyAddress = proxyAddress; + this.proxyPort = proxyPort; + this.proxyAuth = proxyAuth; + this.proxyUsername = proxyUsername; + this.proxyPassword = proxyPassword; + this.timeSynchronizationMethod = timeSynchronizationMethod; + this.timeManualCorrection = timeManualCorrection; + this.cacheEnabled = cacheEnabled; + this.cacheDir = cacheDir; + this.doCacheCleanUp = doCacheCleanUp; + this.storeCredentials = storeCredentials; + this.storedCredentialsFile = storedCredentialsFile; + this.retryOnChunkError = retryOnChunkError; + } + + public static final class Builder { + // Proxy + private boolean proxyEnabled = false; + private Proxy.Type proxyType; + private String proxyAddress; + private int proxyPort; + private boolean proxyAuth; + private String proxyUsername; + private String proxyPassword; + + // Time sync + private TimeProvider.Method timeSynchronizationMethod = TimeProvider.Method.NTP; + private int timeManualCorrection; + + // Cache + private boolean cacheEnabled = true; + private File cacheDir = new File("cache"); + private boolean doCacheCleanUp; + + // Stored credentials + private boolean storeCredentials = true; + private File storedCredentialsFile = new File("credentials.json"); + + // Fetching + private boolean retryOnChunkError; + + public Builder() { + } + + public Builder setProxyEnabled(boolean proxyEnabled) { + this.proxyEnabled = proxyEnabled; + return this; + } + + public Builder setProxyType(Proxy.Type proxyType) { + this.proxyType = proxyType; + return this; + } + + public Builder setProxyAddress(String proxyAddress) { + this.proxyAddress = proxyAddress; + return this; + } + + public Builder setProxyPort(int proxyPort) { + this.proxyPort = proxyPort; + return this; + } + + public Builder setProxyAuth(boolean proxyAuth) { + this.proxyAuth = proxyAuth; + return this; + } + + public Builder setProxyUsername(String proxyUsername) { + this.proxyUsername = proxyUsername; + return this; + } + + public Builder setProxyPassword(String proxyPassword) { + this.proxyPassword = proxyPassword; + return this; + } + + public Builder setTimeSynchronizationMethod(TimeProvider.Method timeSynchronizationMethod) { + this.timeSynchronizationMethod = timeSynchronizationMethod; + return this; + } + + public Builder setTimeManualCorrection(int timeManualCorrection) { + this.timeManualCorrection = timeManualCorrection; + return this; + } + + public Builder setCacheEnabled(boolean cacheEnabled) { + this.cacheEnabled = cacheEnabled; + return this; + } + + public Builder setCacheDir(File cacheDir) { + this.cacheDir = cacheDir; + return this; + } + + public Builder setDoCacheCleanUp(boolean doCacheCleanUp) { + this.doCacheCleanUp = doCacheCleanUp; + return this; + } + + public Builder setStoreCredentials(boolean storeCredentials) { + this.storeCredentials = storeCredentials; + return this; + } + + public Builder setStoredCredentialsFile(File storedCredentialsFile) { + this.storedCredentialsFile = storedCredentialsFile; + return this; + } + + public Builder setRetryOnChunkError(boolean retryOnChunkError) { + this.retryOnChunkError = retryOnChunkError; + return this; + } + + @NotNull + public Configuration build() { + return new Configuration(proxyEnabled, proxyType, proxyAddress, proxyPort, proxyAuth, proxyUsername, proxyPassword, + timeSynchronizationMethod, timeManualCorrection, + cacheEnabled, cacheDir, doCacheCleanUp, + storeCredentials, storedCredentialsFile, + retryOnChunkError); + } + } + } + public static class SpotifyAuthenticationException extends Exception { - private SpotifyAuthenticationException(Keyexchange.APLoginFailed loginFailed) { + private SpotifyAuthenticationException(Keyexchange.@NotNull APLoginFailed loginFailed) { super(loginFailed.getErrorCode().name()); } } @@ -1047,23 +1215,23 @@ private ConnectionHolder(@NotNull Socket socket) throws IOException { } @NotNull - static ConnectionHolder create(@NotNull String addr, @NotNull ProxyConfiguration conf) throws IOException { + static ConnectionHolder create(@NotNull String addr, @NotNull Configuration conf) throws IOException { int colon = addr.indexOf(':'); String apAddr = addr.substring(0, colon); int apPort = Integer.parseInt(addr.substring(colon + 1)); - if (!conf.proxyEnabled() || conf.proxyType() == Proxy.Type.DIRECT) + if (!conf.proxyEnabled || conf.proxyType == Proxy.Type.DIRECT) return new ConnectionHolder(new Socket(apAddr, apPort)); - switch (conf.proxyType()) { + switch (conf.proxyType) { case HTTP: - Socket sock = new Socket(conf.proxyAddress(), conf.proxyPort()); + Socket sock = new Socket(conf.proxyAddress, conf.proxyPort); OutputStream out = sock.getOutputStream(); DataInputStream in = new DataInputStream(sock.getInputStream()); out.write(String.format("CONNECT %s:%d HTTP/1.0\n", apAddr, apPort).getBytes()); - if (conf.proxyAuth()) { + if (conf.proxyAuth) { out.write("Proxy-Authorization: Basic ".getBytes()); - out.write(Base64.getEncoder().encodeToString(String.format("%s:%s\n", conf.proxyUsername(), conf.proxyPassword()).getBytes()).getBytes()); + out.write(Base64.getEncoder().encodeToString(String.format("%s:%s\n", conf.proxyUsername, conf.proxyPassword).getBytes()).getBytes()); } out.write('\n'); @@ -1080,10 +1248,10 @@ static ConnectionHolder create(@NotNull String addr, @NotNull ProxyConfiguration LOGGER.info("Successfully connected to the HTTP proxy."); return new ConnectionHolder(sock); case SOCKS: - if (conf.proxyAuth()) { + if (conf.proxyAuth) { java.net.Authenticator.setDefault(new java.net.Authenticator() { - final String username = conf.proxyUsername(); - final String password = conf.proxyPassword(); + final String username = conf.proxyUsername; + final String password = conf.proxyPassword; @Override protected PasswordAuthentication getPasswordAuthentication() { @@ -1095,12 +1263,11 @@ protected PasswordAuthentication getPasswordAuthentication() { }); } - Proxy proxy = new Proxy(conf.proxyType(), new InetSocketAddress(conf.proxyAddress(), conf.proxyPort())); + Proxy proxy = new Proxy(conf.proxyType, new InetSocketAddress(conf.proxyAddress, conf.proxyPort)); Socket proxySocket = new Socket(proxy); proxySocket.connect(new InetSocketAddress(apAddr, apPort)); LOGGER.info("Successfully connected to the SOCKS proxy."); return new ConnectionHolder(proxySocket); - case DIRECT: default: throw new UnsupportedOperationException(); } diff --git a/core/src/main/java/xyz/gianlu/librespot/core/TimeProvider.java b/lib/src/main/java/xyz/gianlu/librespot/core/TimeProvider.java similarity index 92% rename from core/src/main/java/xyz/gianlu/librespot/core/TimeProvider.java rename to lib/src/main/java/xyz/gianlu/librespot/core/TimeProvider.java index fe60ad2c..16a9f1ee 100644 --- a/core/src/main/java/xyz/gianlu/librespot/core/TimeProvider.java +++ b/lib/src/main/java/xyz/gianlu/librespot/core/TimeProvider.java @@ -29,8 +29,8 @@ public final class TimeProvider { private TimeProvider() { } - public static void init(@NotNull Configuration conf) { - switch (method = conf.timeSynchronizationMethod()) { + public static void init(@NotNull Session.Configuration conf) { + switch (method = conf.timeSynchronizationMethod) { case NTP: try { updateWithNtp(); @@ -40,7 +40,7 @@ public static void init(@NotNull Configuration conf) { break; case MANUAL: synchronized (offset) { - offset.set(conf.timeManualCorrection()); + offset.set(conf.timeManualCorrection); } break; default: @@ -125,10 +125,4 @@ public static void updateWithPing(byte[] pingPayload) { public enum Method { NTP, PING, MELODY, MANUAL } - - public interface Configuration { - @NotNull Method timeSynchronizationMethod(); - - int timeManualCorrection(); - } } diff --git a/core/src/main/java/xyz/gianlu/librespot/core/TokenProvider.java b/lib/src/main/java/xyz/gianlu/librespot/core/TokenProvider.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/core/TokenProvider.java rename to lib/src/main/java/xyz/gianlu/librespot/core/TokenProvider.java diff --git a/core/src/main/java/xyz/gianlu/librespot/crypto/CipherPair.java b/lib/src/main/java/xyz/gianlu/librespot/crypto/CipherPair.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/crypto/CipherPair.java rename to lib/src/main/java/xyz/gianlu/librespot/crypto/CipherPair.java diff --git a/core/src/main/java/xyz/gianlu/librespot/crypto/DiffieHellman.java b/lib/src/main/java/xyz/gianlu/librespot/crypto/DiffieHellman.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/crypto/DiffieHellman.java rename to lib/src/main/java/xyz/gianlu/librespot/crypto/DiffieHellman.java diff --git a/core/src/main/java/xyz/gianlu/librespot/crypto/PBKDF2.java b/lib/src/main/java/xyz/gianlu/librespot/crypto/PBKDF2.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/crypto/PBKDF2.java rename to lib/src/main/java/xyz/gianlu/librespot/crypto/PBKDF2.java diff --git a/core/src/main/java/xyz/gianlu/librespot/crypto/Packet.java b/lib/src/main/java/xyz/gianlu/librespot/crypto/Packet.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/crypto/Packet.java rename to lib/src/main/java/xyz/gianlu/librespot/crypto/Packet.java diff --git a/core/src/main/java/xyz/gianlu/librespot/crypto/Shannon.java b/lib/src/main/java/xyz/gianlu/librespot/crypto/Shannon.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/crypto/Shannon.java rename to lib/src/main/java/xyz/gianlu/librespot/crypto/Shannon.java diff --git a/core/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java b/lib/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java similarity index 99% rename from core/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java rename to lib/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java index 2b2d8379..e0c570fc 100644 --- a/core/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java +++ b/lib/src/main/java/xyz/gianlu/librespot/dealer/ApiClient.java @@ -12,7 +12,7 @@ import xyz.gianlu.librespot.core.ApResolver; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; -import xyz.gianlu.librespot.mercury.model.*; +import xyz.gianlu.librespot.metadata.*; import java.io.IOException; diff --git a/core/src/main/java/xyz/gianlu/librespot/dealer/DealerClient.java b/lib/src/main/java/xyz/gianlu/librespot/dealer/DealerClient.java similarity index 99% rename from core/src/main/java/xyz/gianlu/librespot/dealer/DealerClient.java rename to lib/src/main/java/xyz/gianlu/librespot/dealer/DealerClient.java index 7046d49c..fb1b1eed 100644 --- a/core/src/main/java/xyz/gianlu/librespot/dealer/DealerClient.java +++ b/lib/src/main/java/xyz/gianlu/librespot/dealer/DealerClient.java @@ -11,8 +11,8 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.BytesArrayList; import xyz.gianlu.librespot.common.AsyncWorker; +import xyz.gianlu.librespot.common.BytesArrayList; import xyz.gianlu.librespot.common.NameThreadFactory; import xyz.gianlu.librespot.core.ApResolver; import xyz.gianlu.librespot.core.Session; diff --git a/core/src/main/java/xyz/gianlu/librespot/dealer/MessageType.java b/lib/src/main/java/xyz/gianlu/librespot/dealer/MessageType.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/dealer/MessageType.java rename to lib/src/main/java/xyz/gianlu/librespot/dealer/MessageType.java diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/JsonMercuryRequest.java b/lib/src/main/java/xyz/gianlu/librespot/mercury/JsonMercuryRequest.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/mercury/JsonMercuryRequest.java rename to lib/src/main/java/xyz/gianlu/librespot/mercury/JsonMercuryRequest.java diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/JsonWrapper.java b/lib/src/main/java/xyz/gianlu/librespot/mercury/JsonWrapper.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/mercury/JsonWrapper.java rename to lib/src/main/java/xyz/gianlu/librespot/mercury/JsonWrapper.java diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/MercuryClient.java b/lib/src/main/java/xyz/gianlu/librespot/mercury/MercuryClient.java similarity index 99% rename from core/src/main/java/xyz/gianlu/librespot/mercury/MercuryClient.java rename to lib/src/main/java/xyz/gianlu/librespot/mercury/MercuryClient.java index 9906b7c3..545256ff 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/MercuryClient.java +++ b/lib/src/main/java/xyz/gianlu/librespot/mercury/MercuryClient.java @@ -10,7 +10,7 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.BytesArrayList; +import xyz.gianlu.librespot.common.BytesArrayList; import xyz.gianlu.librespot.common.ProtobufToJson; import xyz.gianlu.librespot.common.Utils; import xyz.gianlu.librespot.core.PacketsManager; diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/MercuryRequests.java b/lib/src/main/java/xyz/gianlu/librespot/mercury/MercuryRequests.java similarity index 99% rename from core/src/main/java/xyz/gianlu/librespot/mercury/MercuryRequests.java rename to lib/src/main/java/xyz/gianlu/librespot/mercury/MercuryRequests.java index c7b2793b..d04d7f9c 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/MercuryRequests.java +++ b/lib/src/main/java/xyz/gianlu/librespot/mercury/MercuryRequests.java @@ -11,7 +11,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import xyz.gianlu.librespot.common.ProtoUtils; -import xyz.gianlu.librespot.mercury.model.*; +import xyz.gianlu.librespot.metadata.*; import java.util.List; diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/ProtobufMercuryRequest.java b/lib/src/main/java/xyz/gianlu/librespot/mercury/ProtobufMercuryRequest.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/mercury/ProtobufMercuryRequest.java rename to lib/src/main/java/xyz/gianlu/librespot/mercury/ProtobufMercuryRequest.java diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/RawMercuryRequest.java b/lib/src/main/java/xyz/gianlu/librespot/mercury/RawMercuryRequest.java similarity index 98% rename from core/src/main/java/xyz/gianlu/librespot/mercury/RawMercuryRequest.java rename to lib/src/main/java/xyz/gianlu/librespot/mercury/RawMercuryRequest.java index 5b2854fa..5a388130 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/RawMercuryRequest.java +++ b/lib/src/main/java/xyz/gianlu/librespot/mercury/RawMercuryRequest.java @@ -4,7 +4,7 @@ import com.google.protobuf.ByteString; import com.spotify.Mercury; import org.jetbrains.annotations.NotNull; -import xyz.gianlu.librespot.BytesArrayList; +import xyz.gianlu.librespot.common.BytesArrayList; /** * @author Gianlu diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/SubListener.java b/lib/src/main/java/xyz/gianlu/librespot/mercury/SubListener.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/mercury/SubListener.java rename to lib/src/main/java/xyz/gianlu/librespot/mercury/SubListener.java diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/AlbumId.java b/lib/src/main/java/xyz/gianlu/librespot/metadata/AlbumId.java similarity index 97% rename from core/src/main/java/xyz/gianlu/librespot/mercury/model/AlbumId.java rename to lib/src/main/java/xyz/gianlu/librespot/metadata/AlbumId.java index ec082135..ea021a1f 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/AlbumId.java +++ b/lib/src/main/java/xyz/gianlu/librespot/metadata/AlbumId.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.mercury.model; +package xyz.gianlu.librespot.metadata; import org.jetbrains.annotations.NotNull; import xyz.gianlu.librespot.common.Base62; diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/ArtistId.java b/lib/src/main/java/xyz/gianlu/librespot/metadata/ArtistId.java similarity index 97% rename from core/src/main/java/xyz/gianlu/librespot/mercury/model/ArtistId.java rename to lib/src/main/java/xyz/gianlu/librespot/metadata/ArtistId.java index 802a56f2..1cee1f2f 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/ArtistId.java +++ b/lib/src/main/java/xyz/gianlu/librespot/metadata/ArtistId.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.mercury.model; +package xyz.gianlu.librespot.metadata; import org.jetbrains.annotations.NotNull; import xyz.gianlu.librespot.common.Base62; diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/EpisodeId.java b/lib/src/main/java/xyz/gianlu/librespot/metadata/EpisodeId.java similarity index 97% rename from core/src/main/java/xyz/gianlu/librespot/mercury/model/EpisodeId.java rename to lib/src/main/java/xyz/gianlu/librespot/metadata/EpisodeId.java index dbb6dee4..a97c19f3 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/EpisodeId.java +++ b/lib/src/main/java/xyz/gianlu/librespot/metadata/EpisodeId.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.mercury.model; +package xyz.gianlu.librespot.metadata; import org.jetbrains.annotations.NotNull; import xyz.gianlu.librespot.common.Base62; diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/ImageId.java b/lib/src/main/java/xyz/gianlu/librespot/metadata/ImageId.java similarity index 98% rename from core/src/main/java/xyz/gianlu/librespot/mercury/model/ImageId.java rename to lib/src/main/java/xyz/gianlu/librespot/metadata/ImageId.java index 9a322ca9..2a421b09 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/ImageId.java +++ b/lib/src/main/java/xyz/gianlu/librespot/metadata/ImageId.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.mercury.model; +package xyz.gianlu.librespot.metadata; import com.spotify.connectstate.Player; import com.spotify.metadata.Metadata; diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/PlayableId.java b/lib/src/main/java/xyz/gianlu/librespot/metadata/PlayableId.java similarity index 98% rename from core/src/main/java/xyz/gianlu/librespot/mercury/model/PlayableId.java rename to lib/src/main/java/xyz/gianlu/librespot/metadata/PlayableId.java index 76fca5e1..521372ab 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/PlayableId.java +++ b/lib/src/main/java/xyz/gianlu/librespot/metadata/PlayableId.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.mercury.model; +package xyz.gianlu.librespot.metadata; import com.google.protobuf.ByteString; import com.spotify.connectstate.Player; diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/PlaylistId.java b/lib/src/main/java/xyz/gianlu/librespot/metadata/PlaylistId.java similarity index 96% rename from core/src/main/java/xyz/gianlu/librespot/mercury/model/PlaylistId.java rename to lib/src/main/java/xyz/gianlu/librespot/metadata/PlaylistId.java index ab703059..7bdcb099 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/PlaylistId.java +++ b/lib/src/main/java/xyz/gianlu/librespot/metadata/PlaylistId.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.mercury.model; +package xyz.gianlu.librespot.metadata; import org.jetbrains.annotations.NotNull; diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/ShowId.java b/lib/src/main/java/xyz/gianlu/librespot/metadata/ShowId.java similarity index 97% rename from core/src/main/java/xyz/gianlu/librespot/mercury/model/ShowId.java rename to lib/src/main/java/xyz/gianlu/librespot/metadata/ShowId.java index 0a80f7f5..c7e29d61 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/ShowId.java +++ b/lib/src/main/java/xyz/gianlu/librespot/metadata/ShowId.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.mercury.model; +package xyz.gianlu.librespot.metadata; import org.jetbrains.annotations.NotNull; import xyz.gianlu.librespot.common.Base62; diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/SpotifyId.java b/lib/src/main/java/xyz/gianlu/librespot/metadata/SpotifyId.java similarity index 97% rename from core/src/main/java/xyz/gianlu/librespot/mercury/model/SpotifyId.java rename to lib/src/main/java/xyz/gianlu/librespot/metadata/SpotifyId.java index cd7d7c8a..4b0b4e8f 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/SpotifyId.java +++ b/lib/src/main/java/xyz/gianlu/librespot/metadata/SpotifyId.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.mercury.model; +package xyz.gianlu.librespot.metadata; import org.jetbrains.annotations.NotNull; diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/TrackId.java b/lib/src/main/java/xyz/gianlu/librespot/metadata/TrackId.java similarity index 97% rename from core/src/main/java/xyz/gianlu/librespot/mercury/model/TrackId.java rename to lib/src/main/java/xyz/gianlu/librespot/metadata/TrackId.java index 08fc952d..42963173 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/TrackId.java +++ b/lib/src/main/java/xyz/gianlu/librespot/metadata/TrackId.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.mercury.model; +package xyz.gianlu.librespot.metadata; import org.jetbrains.annotations.NotNull; import xyz.gianlu.librespot.common.Base62; diff --git a/core/src/main/java/xyz/gianlu/librespot/mercury/model/UnsupportedId.java b/lib/src/main/java/xyz/gianlu/librespot/metadata/UnsupportedId.java similarity index 93% rename from core/src/main/java/xyz/gianlu/librespot/mercury/model/UnsupportedId.java rename to lib/src/main/java/xyz/gianlu/librespot/metadata/UnsupportedId.java index e9a2d02e..a7e4ae8f 100644 --- a/core/src/main/java/xyz/gianlu/librespot/mercury/model/UnsupportedId.java +++ b/lib/src/main/java/xyz/gianlu/librespot/metadata/UnsupportedId.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.mercury.model; +package xyz.gianlu.librespot.metadata; import org.jetbrains.annotations.NotNull; diff --git a/common/src/main/proto/authentication.proto b/lib/src/main/proto/authentication.proto similarity index 100% rename from common/src/main/proto/authentication.proto rename to lib/src/main/proto/authentication.proto diff --git a/common/src/main/proto/canvaz-meta.proto b/lib/src/main/proto/canvaz-meta.proto similarity index 100% rename from common/src/main/proto/canvaz-meta.proto rename to lib/src/main/proto/canvaz-meta.proto diff --git a/common/src/main/proto/canvaz.proto b/lib/src/main/proto/canvaz.proto similarity index 100% rename from common/src/main/proto/canvaz.proto rename to lib/src/main/proto/canvaz.proto diff --git a/common/src/main/proto/connect.proto b/lib/src/main/proto/connect.proto similarity index 100% rename from common/src/main/proto/connect.proto rename to lib/src/main/proto/connect.proto diff --git a/common/src/main/proto/context.proto b/lib/src/main/proto/context.proto similarity index 100% rename from common/src/main/proto/context.proto rename to lib/src/main/proto/context.proto diff --git a/common/src/main/proto/context_page.proto b/lib/src/main/proto/context_page.proto similarity index 100% rename from common/src/main/proto/context_page.proto rename to lib/src/main/proto/context_page.proto diff --git a/common/src/main/proto/context_player_options.proto b/lib/src/main/proto/context_player_options.proto similarity index 100% rename from common/src/main/proto/context_player_options.proto rename to lib/src/main/proto/context_player_options.proto diff --git a/common/src/main/proto/context_track.proto b/lib/src/main/proto/context_track.proto similarity index 100% rename from common/src/main/proto/context_track.proto rename to lib/src/main/proto/context_track.proto diff --git a/common/src/main/proto/explicit_content_pubsub.proto b/lib/src/main/proto/explicit_content_pubsub.proto similarity index 100% rename from common/src/main/proto/explicit_content_pubsub.proto rename to lib/src/main/proto/explicit_content_pubsub.proto diff --git a/common/src/main/proto/keyexchange.proto b/lib/src/main/proto/keyexchange.proto similarity index 100% rename from common/src/main/proto/keyexchange.proto rename to lib/src/main/proto/keyexchange.proto diff --git a/common/src/main/proto/mercury.proto b/lib/src/main/proto/mercury.proto similarity index 100% rename from common/src/main/proto/mercury.proto rename to lib/src/main/proto/mercury.proto diff --git a/common/src/main/proto/metadata.proto b/lib/src/main/proto/metadata.proto similarity index 100% rename from common/src/main/proto/metadata.proto rename to lib/src/main/proto/metadata.proto diff --git a/common/src/main/proto/play_origin.proto b/lib/src/main/proto/play_origin.proto similarity index 100% rename from common/src/main/proto/play_origin.proto rename to lib/src/main/proto/play_origin.proto diff --git a/common/src/main/proto/playback.proto b/lib/src/main/proto/playback.proto similarity index 100% rename from common/src/main/proto/playback.proto rename to lib/src/main/proto/playback.proto diff --git a/common/src/main/proto/player.proto b/lib/src/main/proto/player.proto similarity index 100% rename from common/src/main/proto/player.proto rename to lib/src/main/proto/player.proto diff --git a/common/src/main/proto/playlist4_external.proto b/lib/src/main/proto/playlist4_external.proto similarity index 100% rename from common/src/main/proto/playlist4_external.proto rename to lib/src/main/proto/playlist4_external.proto diff --git a/common/src/main/proto/playlist_annotate3.proto b/lib/src/main/proto/playlist_annotate3.proto similarity index 100% rename from common/src/main/proto/playlist_annotate3.proto rename to lib/src/main/proto/playlist_annotate3.proto diff --git a/common/src/main/proto/pubsub.proto b/lib/src/main/proto/pubsub.proto similarity index 100% rename from common/src/main/proto/pubsub.proto rename to lib/src/main/proto/pubsub.proto diff --git a/common/src/main/proto/queue.proto b/lib/src/main/proto/queue.proto similarity index 100% rename from common/src/main/proto/queue.proto rename to lib/src/main/proto/queue.proto diff --git a/common/src/main/proto/restrictions.proto b/lib/src/main/proto/restrictions.proto similarity index 100% rename from common/src/main/proto/restrictions.proto rename to lib/src/main/proto/restrictions.proto diff --git a/common/src/main/proto/session.proto b/lib/src/main/proto/session.proto similarity index 100% rename from common/src/main/proto/session.proto rename to lib/src/main/proto/session.proto diff --git a/common/src/main/proto/spotify/login5/v3/challenges/code.proto b/lib/src/main/proto/spotify/login5/v3/challenges/code.proto similarity index 100% rename from common/src/main/proto/spotify/login5/v3/challenges/code.proto rename to lib/src/main/proto/spotify/login5/v3/challenges/code.proto diff --git a/common/src/main/proto/spotify/login5/v3/challenges/hashcash.proto b/lib/src/main/proto/spotify/login5/v3/challenges/hashcash.proto similarity index 100% rename from common/src/main/proto/spotify/login5/v3/challenges/hashcash.proto rename to lib/src/main/proto/spotify/login5/v3/challenges/hashcash.proto diff --git a/common/src/main/proto/spotify/login5/v3/client_info.proto b/lib/src/main/proto/spotify/login5/v3/client_info.proto similarity index 100% rename from common/src/main/proto/spotify/login5/v3/client_info.proto rename to lib/src/main/proto/spotify/login5/v3/client_info.proto diff --git a/common/src/main/proto/spotify/login5/v3/credentials/credentials.proto b/lib/src/main/proto/spotify/login5/v3/credentials/credentials.proto similarity index 100% rename from common/src/main/proto/spotify/login5/v3/credentials/credentials.proto rename to lib/src/main/proto/spotify/login5/v3/credentials/credentials.proto diff --git a/common/src/main/proto/spotify/login5/v3/identifiers/identifiers.proto b/lib/src/main/proto/spotify/login5/v3/identifiers/identifiers.proto similarity index 100% rename from common/src/main/proto/spotify/login5/v3/identifiers/identifiers.proto rename to lib/src/main/proto/spotify/login5/v3/identifiers/identifiers.proto diff --git a/common/src/main/proto/spotify/login5/v3/login5.proto b/lib/src/main/proto/spotify/login5/v3/login5.proto similarity index 100% rename from common/src/main/proto/spotify/login5/v3/login5.proto rename to lib/src/main/proto/spotify/login5/v3/login5.proto diff --git a/common/src/main/proto/spotify/login5/v3/user_info.proto b/lib/src/main/proto/spotify/login5/v3/user_info.proto similarity index 100% rename from common/src/main/proto/spotify/login5/v3/user_info.proto rename to lib/src/main/proto/spotify/login5/v3/user_info.proto diff --git a/common/src/main/proto/storage-resolve.proto b/lib/src/main/proto/storage-resolve.proto similarity index 100% rename from common/src/main/proto/storage-resolve.proto rename to lib/src/main/proto/storage-resolve.proto diff --git a/common/src/main/proto/transfer_state.proto b/lib/src/main/proto/transfer_state.proto similarity index 100% rename from common/src/main/proto/transfer_state.proto rename to lib/src/main/proto/transfer_state.proto diff --git a/common/src/main/resources/log4j2.xml b/lib/src/main/resources/log4j2.xml similarity index 100% rename from common/src/main/resources/log4j2.xml rename to lib/src/main/resources/log4j2.xml diff --git a/core/src/test/java/xyz/gianlu/librespot/SpotifyUrisTest.java b/lib/src/main/test/xyz/gianlu/librespot/SpotifyUrisTest.java similarity index 91% rename from core/src/test/java/xyz/gianlu/librespot/SpotifyUrisTest.java rename to lib/src/main/test/xyz/gianlu/librespot/SpotifyUrisTest.java index 056a6eb1..ff8d8c25 100644 --- a/core/src/test/java/xyz/gianlu/librespot/SpotifyUrisTest.java +++ b/lib/src/main/test/xyz/gianlu/librespot/SpotifyUrisTest.java @@ -2,8 +2,8 @@ import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.Test; -import xyz.gianlu.librespot.mercury.model.EpisodeId; -import xyz.gianlu.librespot.mercury.model.TrackId; +import xyz.gianlu.librespot.metadata.EpisodeId; +import xyz.gianlu.librespot.metadata.TrackId; import java.util.Arrays; diff --git a/core/src/test/java/xyz/gianlu/librespot/cache/CacheTest.java b/lib/src/main/test/xyz/gianlu/librespot/cache/CacheTest.java similarity index 96% rename from core/src/test/java/xyz/gianlu/librespot/cache/CacheTest.java rename to lib/src/main/test/xyz/gianlu/librespot/cache/CacheTest.java index bb171161..4363f2ec 100644 --- a/core/src/test/java/xyz/gianlu/librespot/cache/CacheTest.java +++ b/lib/src/main/test/xyz/gianlu/librespot/cache/CacheTest.java @@ -1,6 +1,5 @@ package xyz.gianlu.librespot.cache; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import xyz.gianlu.librespot.common.Utils; @@ -39,7 +38,7 @@ private void assertSequence(int start, String seq) { if (lastContent == null) throw new IllegalStateException(); if (!lastContent.substring(start).startsWith(seq)) - Assertions.fail(String.format("Sequence doesn't match, wanted '%s', but is '%s'!", seq, lastContent.substring(start))); + fail(String.format("Sequence doesn't match, wanted '%s', but is '%s'!", seq, lastContent.substring(start))); } void testChunks(CacheJournal journal) throws IOException { diff --git a/common/src/test/java/xyz/gianlu/librespot/common/AsyncProcessorTest.java b/lib/src/main/test/xyz/gianlu/librespot/common/AsyncProcessorTest.java similarity index 100% rename from common/src/test/java/xyz/gianlu/librespot/common/AsyncProcessorTest.java rename to lib/src/main/test/xyz/gianlu/librespot/common/AsyncProcessorTest.java diff --git a/common/src/test/java/xyz/gianlu/librespot/common/FisherYatesTest.java b/lib/src/main/test/xyz/gianlu/librespot/common/FisherYatesTest.java similarity index 100% rename from common/src/test/java/xyz/gianlu/librespot/common/FisherYatesTest.java rename to lib/src/main/test/xyz/gianlu/librespot/common/FisherYatesTest.java diff --git a/player/README.md b/player/README.md new file mode 100644 index 00000000..6ce1341f --- /dev/null +++ b/player/README.md @@ -0,0 +1,90 @@ +# Player +This module allows running `librespot-java` in headless mode as a Spotify Connect device. + +## 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 +- Stored credentials + +The suggested way to authenticate if you're not considering Zeroconf is to: +1) Enable stored credentials: + ```toml + [auth] + storeCredentials = true + credentialsFile = "some_file.json" + ``` +2) Authenticate with username and password: + ```toml + [auth] + strategy = "USER_PASS" + username = "" + password = "" + ``` +3) Set authentication strategy to stored credentials and remove sensible data: + ```toml + [auth] + strategy = "STORED" + username = "" + password = "" + ``` + +### Username and password +> ```toml +> [auth] +> strategy = "USER_PASS" +> username = "" +> password = "" +> ``` +This is the simplest authentication method, but less secure because you'll have a plaintext password in your configuration file. + +### Zeroconf +> ```toml +> [auth] +> strategy = "ZEROCONF" +> ``` +Becomes discoverable with Spotify Connect by devices on the same network, connect from the 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 +> ```toml +> [auth] +> strategy = "FACEBOOK" +> ``` +Authenticate with Facebook. The console will provide a link to visit in order to continue the login process. + +### Auth blob +> ```toml +> [auth] +> strategy = "BLOB" +> blob = "dGhpcyBpcyBzb21lIGJhc2U2NCBkYXRhIQ==" +> ``` +This is more advanced and should only be used if you saved an authentication blob. The blob should be in Base64 format. Generating one is currently not a feature of librespot-java. + +### Stored credentials +> ```toml +> [auth] +> strategy = "STORED" +> credentialsFile = "some_file.json" +> ``` +Stored credentials are generated and saved into `auth.credentialsFile` if `auth.storeCredentials` is set to `true` and `auth.strategy` is not `ZEROCONF`. The file created is a JSON file that allows you to authenticate without having plaintext passwords in your configuration file (and without triggering a login email). + + +## Run +You can download the latest release from [here](https://github.com/librespot-org/librespot-java/releases) and then run `java -jar librespot-player-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 `player/target`. +To run the newly build jar run `java -jar player/target/librespot-player-jar-with-dependencies.jar`. \ No newline at end of file diff --git a/core/pom.xml b/player/pom.xml similarity index 76% rename from core/pom.xml rename to player/pom.xml index 2d148f59..932883b0 100644 --- a/core/pom.xml +++ b/player/pom.xml @@ -9,13 +9,13 @@ ../ - librespot-core + librespot-player jar - librespot-java core + librespot-java player - librespot-core + librespot-player org.apache.maven.plugins @@ -32,7 +32,7 @@ true true - xyz.gianlu.librespot.Main + xyz.gianlu.librespot.player.Main true @@ -51,7 +51,7 @@ xyz.gianlu.librespot - librespot-common + librespot-lib ${project.version} @@ -67,32 +67,11 @@ 1.0.2-gdx - - - xyz.gianlu.zeroconf - zeroconf - 1.1.3 - - - - - com.squareup.okhttp3 - okhttp - 4.6.0 - - com.electronwill.night-config toml 3.6.3 - - - - commons-net - commons-net - 3.6 - \ No newline at end of file diff --git a/core/src/main/java/xyz/gianlu/librespot/player/AudioOutput.java b/player/src/main/java/xyz/gianlu/librespot/player/AudioOutput.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/AudioOutput.java rename to player/src/main/java/xyz/gianlu/librespot/player/AudioOutput.java diff --git a/core/src/main/java/xyz/gianlu/librespot/FileConfiguration.java b/player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java similarity index 67% rename from core/src/main/java/xyz/gianlu/librespot/FileConfiguration.java rename to player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java index d09fdd37..27dfe47a 100644 --- a/core/src/main/java/xyz/gianlu/librespot/FileConfiguration.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/FileConfiguration.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot; +package xyz.gianlu.librespot.player; import com.electronwill.nightconfig.core.CommentedConfig; import com.electronwill.nightconfig.core.Config; @@ -17,11 +17,10 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.ZeroconfServer; import xyz.gianlu.librespot.common.Utils; +import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.core.TimeProvider; -import xyz.gianlu.librespot.core.ZeroconfServer; -import xyz.gianlu.librespot.player.AudioOutput; -import xyz.gianlu.librespot.player.Player; import xyz.gianlu.librespot.player.codecs.AudioQuality; import java.io.File; @@ -29,16 +28,14 @@ import java.io.IOException; import java.io.InputStream; import java.net.Proxy; -import java.util.ArrayList; -import java.util.Map; -import java.util.Objects; -import java.util.Properties; +import java.security.GeneralSecurityException; +import java.util.*; import java.util.function.Supplier; /** * @author Gianlu */ -public final class FileConfiguration extends AbsConfiguration { +public final class FileConfiguration { private static final Logger LOGGER = LogManager.getLogger(FileConfiguration.class); static { @@ -71,7 +68,8 @@ public FileConfiguration(@Nullable String... override) throws IOException { if (migrating) { migrateOldConfig(confFile, config); config.save(); - confFile.delete(); + if (!confFile.delete()) + LOGGER.warn("Failed deleting old configuration file."); LOGGER.info("Your configuration has been migrated to `config.toml`, change your input file if needed."); } else { @@ -202,23 +200,7 @@ private String[] getStringArray(@NotNull String key, char separator) { else return Utils.split(str, separator); } - @Override - public boolean cacheEnabled() { - return config.get("cache.enabled"); - } - - @Override - public @NotNull File cacheDir() { - return new File((String) config.get("cache.dir")); - } - - @Override - public boolean doCleanUp() { - return config.get("cache.doCleanUp"); - } - - @Override - public @NotNull AudioQuality preferredQuality() { + private @NotNull AudioQuality preferredQuality() { try { return config.getEnum("player.preferredAudioQuality", AudioQuality.class); } catch (IllegalArgumentException ex) { // Retro-compatibility @@ -238,37 +220,19 @@ public boolean doCleanUp() { } } - @Override - public @NotNull AudioOutput output() { - return config.getEnum("player.output", AudioOutput.class); - } - - @Override - public @Nullable File outputPipe() { + private @Nullable File outputPipe() { String path = config.get("player.pipe"); if (path == null || path.isEmpty()) return null; return new File(path); } - @Override - public @Nullable File metadataPipe() { + private @Nullable File metadataPipe() { String path = config.get("player.metadataPipe"); if (path == null || path.isEmpty()) return null; return new File(path); } - @Override - public boolean preloadEnabled() { - return config.get("preload.enabled"); - } - - @Override - public boolean enableNormalisation() { - return config.get("player.enableNormalisation"); - } - - @Override - public float normalisationPregain() { + private float normalisationPregain() { Object raw = config.get("player.normalisationPregain"); if (raw instanceof String) { return Float.parseFloat((String) raw); @@ -281,189 +245,145 @@ public float normalisationPregain() { } } - @NotNull - @Override - public String[] mixerSearchKeywords() { - return getStringArray("player.mixerSearchKeywords", ';'); - } - - @Override - public boolean logAvailableMixers() { - return config.get("player.logAvailableMixers"); - } - - @Override - public int initialVolume() { - int vol = config.get("player.initialVolume"); - if (vol < 0 || vol > Player.VOLUME_MAX) - throw new IllegalArgumentException("Invalid volume: " + vol); - - return vol; - } - - @Override - public int volumeSteps() { - int volumeSteps = config.get("player.volumeSteps"); - if (volumeSteps < 0 || volumeSteps > Player.VOLUME_MAX) - throw new IllegalArgumentException("Invalid volume steps: " + volumeSteps); - - return volumeSteps; - } - - @Override - public boolean autoplayEnabled() { - return config.get("player.autoplayEnabled"); - } - - @Override - public int crossfadeDuration() { - return config.get("player.crossfadeDuration"); - } - - @Override - public int releaseLineDelay() { - return config.get("player.releaseLineDelay"); - } - - @Override - public boolean stopPlaybackOnChunkError() { - return config.get("player.stopPlaybackOnChunkError"); - } - - @Override - public @Nullable String deviceId() { - return config.get("deviceId"); + private @Nullable String deviceId() { + String val = config.get("deviceId"); + return val == null || val.isEmpty() ? null : val; } - @Override - public @Nullable String deviceName() { + private @NotNull String deviceName() { return config.get("deviceName"); } - @Override - public @Nullable Connect.DeviceType deviceType() { + private @NotNull Connect.DeviceType deviceType() { return config.getEnum("deviceType", Connect.DeviceType.class); } - @Override - public @NotNull String preferredLocale() { + private @NotNull String preferredLocale() { return config.get("preferredLocale"); } - @Override - public @NotNull Level loggingLevel() { - String str = config.get("logLevel"); - return Level.toLevel(str); - } - - @Override - public @Nullable String authUsername() { + private @NotNull String authUsername() { return config.get("auth.username"); } - @Override - public @Nullable String authPassword() { + private @NotNull String authPassword() { return config.get("auth.password"); } - @Override - public @Nullable String authBlob() { + private @NotNull String authBlob() { return config.get("auth.blob"); } - @NotNull - @Override - public Strategy authStrategy() { - return config.getEnum("auth.strategy", Strategy.class); - } - - @Override - public boolean storeCredentials() { - return config.get("auth.storeCredentials"); - } - - @Override - public @Nullable File credentialsFile() { + private @Nullable File credentialsFile() { String path = config.get("auth.credentialsFile"); if (path == null || path.isEmpty()) return null; return new File(path); } - @Override - public boolean zeroconfListenAll() { - return config.get("zeroconf.listenAll"); - } - - @Override - public int zeroconfListenPort() { - int val = config.get("zeroconf.listenPort"); - if (val == -1) return val; - - if (val < ZeroconfServer.MIN_PORT || val > ZeroconfServer.MAX_PORT) - throw new IllegalArgumentException("Illegal port number: " + val); - - return val; + public @NotNull Level loggingLevel() { + return Level.toLevel(config.get("logLevel")); } @NotNull - @Override - public String[] zeroconfInterfaces() { - return getStringArray("zeroconf.interfaces", ','); - } - - @Override - public TimeProvider.@NotNull Method timeSynchronizationMethod() { - return config.getEnum("time.synchronizationMethod", TimeProvider.Method.class); + public FileConfiguration.AuthStrategy authStrategy() { + return config.getEnum("auth.strategy", AuthStrategy.class); } - @Override - public int timeManualCorrection() { - return config.get("time.manualCorrection"); - } - - @Override public int apiPort() { return config.get("api.port"); } - @Override public @NotNull String apiHost() { return config.get("api.host"); } - @Override - public boolean proxyEnabled() { - return config.get("proxy.enabled"); - } + @NotNull + public ZeroconfServer.Builder initZeroconfBuilder() { + ZeroconfServer.Builder builder = new ZeroconfServer.Builder(toSession()) + .setPreferredLocale(preferredLocale()) + .setDeviceType(deviceType()) + .setDeviceName(deviceName()) + .setDeviceId(deviceId()) + .setListenPort(config.get("zeroconf.listenPort")); - @Override - public @NotNull Proxy.Type proxyType() { - return config.getEnum("proxy.type", Proxy.Type.class); - } + if (config.get("zeroconf.listenAll")) builder.setListenAll(true); + else builder.setListenInterfaces(getStringArray("zeroconf.interfaces", ',')); - @Override - public @NotNull String proxyAddress() { - return config.get("proxy.address"); + return builder; } - @Override - public int proxyPort() { - return config.get("proxy.port"); - } + @NotNull + public Session.Builder initSessionBuilder() throws IOException, GeneralSecurityException { + Session.Builder builder = new Session.Builder(toSession()) + .setPreferredLocale(preferredLocale()) + .setDeviceType(deviceType()) + .setDeviceName(deviceName()) + .setDeviceId(deviceId()); + + switch (authStrategy()) { + case FACEBOOK: + builder.facebook(); + break; + case BLOB: + builder.blob(authUsername(), Base64.getDecoder().decode(authBlob())); + break; + case USER_PASS: + builder.userPass(authUsername(), authPassword()); + break; + case STORED: + builder.stored(); + break; + case ZEROCONF: + default: + throw new IllegalArgumentException(authStrategy().name()); + } - @Override - public boolean proxyAuth() { - return config.get("proxy.auth"); + return builder; } - @Override - public @NotNull String proxyUsername() { - return config.get("proxy.username"); + @NotNull + public Session.Configuration toSession() { + return new Session.Configuration.Builder() + .setCacheEnabled(config.get("cache.enabled")) + .setCacheDir(new File((String) config.get("cache.dir"))) + .setDoCacheCleanUp(config.get("cache.doCleanUp")) + .setStoreCredentials(config.get("auth.storeCredentials")) + .setStoredCredentialsFile(credentialsFile()) + .setTimeSynchronizationMethod(config.getEnum("time.synchronizationMethod", TimeProvider.Method.class)) + .setTimeManualCorrection(config.get("time.manualCorrection")) + .setProxyEnabled(config.get("proxy.enabled")) + .setProxyType(config.getEnum("proxy.type", Proxy.Type.class)) + .setProxyAddress(config.get("proxy.address")) + .setProxyPort(config.get("proxy.port")) + .setProxyAuth(config.get("proxy.auth")) + .setProxyUsername(config.get("proxy.username")) + .setProxyPassword(config.get("proxy.password")) + .setRetryOnChunkError(config.get("player.retryOnChunkError")) + .build(); } - @Override - public @NotNull String proxyPassword() { - return config.get("proxy.password"); + @NotNull + public PlayerConfiguration toPlayer() { + return new PlayerConfiguration.Builder() + .setAutoplayEnabled(config.get("player.autoplayEnabled")) + .setCrossfadeDuration(config.get("player.crossfadeDuration")) + .setEnableNormalisation(config.get("player.enableNormalisation")) + .setInitialVolume(config.get("player.initialVolume")) + .setLogAvailableMixers(config.get("player.logAvailableMixers")) + .setMetadataPipe(metadataPipe()) + .setMixerSearchKeywords(getStringArray("player.mixerSearchKeywords", ';')) + .setNormalisationPregain(normalisationPregain()) + .setOutput(config.getEnum("player.output", AudioOutput.class)) + .setOutputPipe(outputPipe()) + .setPreferredQuality(preferredQuality()) + .setPreloadEnabled(config.get("preload.enabled")) + .setReleaseLineDelay(config.get("player.releaseLineDelay")) + .setVolumeSteps(config.get("player.volumeSteps")) + .build(); + } + + public enum AuthStrategy { + FACEBOOK, BLOB, USER_PASS, ZEROCONF, STORED } private final static class PropertiesFormat implements ConfigFormat { diff --git a/core/src/main/java/xyz/gianlu/librespot/Main.java b/player/src/main/java/xyz/gianlu/librespot/player/Main.java similarity index 51% rename from core/src/main/java/xyz/gianlu/librespot/Main.java rename to player/src/main/java/xyz/gianlu/librespot/player/Main.java index a76536a2..f95c56dd 100644 --- a/core/src/main/java/xyz/gianlu/librespot/Main.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/Main.java @@ -1,11 +1,11 @@ -package xyz.gianlu.librespot; +package xyz.gianlu.librespot.player; import org.apache.logging.log4j.core.config.Configurator; +import org.jetbrains.annotations.NotNull; +import xyz.gianlu.librespot.ZeroconfServer; 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 java.io.IOException; @@ -17,12 +17,28 @@ public class Main { public static void main(String[] args) throws IOException, GeneralSecurityException, Session.SpotifyAuthenticationException, MercuryClient.MercuryException { - AbsConfiguration conf = new FileConfiguration(args); + FileConfiguration conf = new FileConfiguration(args); Configurator.setRootLevel(conf.loggingLevel()); Thread.setDefaultUncaughtExceptionHandler(new Log4JUncaughtExceptionHandler()); - if (conf.authStrategy() == AuthConfiguration.Strategy.ZEROCONF && !conf.hasStoredCredentials()) { - ZeroconfServer server = ZeroconfServer.create(conf); + if (conf.authStrategy() == FileConfiguration.AuthStrategy.ZEROCONF) { + ZeroconfServer server = conf.initZeroconfBuilder().create(); + server.addSessionListener(new ZeroconfServer.SessionListener() { + Player lastPlayer = null; + + { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + if (lastPlayer != null) lastPlayer.close(); + })); + } + + @Override + public void sessionChanged(@NotNull Session session) { + if (lastPlayer != null) lastPlayer.close(); + lastPlayer = new Player(conf.toPlayer(), session); + } + }); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { server.closeSession(); @@ -31,9 +47,12 @@ public static void main(String[] args) throws IOException, GeneralSecurityExcept } })); } else { - Session session = new Session.Builder(conf).create(); + Session session = conf.initSessionBuilder().create(); + Player player = new Player(conf.toPlayer(), session); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { + player.close(); session.close(); } catch (IOException ignored) { } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/PagesLoader.java b/player/src/main/java/xyz/gianlu/librespot/player/PagesLoader.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/PagesLoader.java rename to player/src/main/java/xyz/gianlu/librespot/player/PagesLoader.java diff --git a/core/src/main/java/xyz/gianlu/librespot/player/Player.java b/player/src/main/java/xyz/gianlu/librespot/player/Player.java similarity index 94% rename from core/src/main/java/xyz/gianlu/librespot/player/Player.java rename to player/src/main/java/xyz/gianlu/librespot/player/Player.java index 399689e4..a06b42e5 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/Player.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/Player.java @@ -13,24 +13,26 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Range; +import xyz.gianlu.librespot.audio.AbsChunkedInputStream; +import xyz.gianlu.librespot.audio.PlayableContentFeeder; import xyz.gianlu.librespot.common.NameThreadFactory; -import xyz.gianlu.librespot.connectstate.DeviceStateHandler; -import xyz.gianlu.librespot.connectstate.DeviceStateHandler.PlayCommandHelper; -import xyz.gianlu.librespot.core.EventService.PlaybackMetrics; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; import xyz.gianlu.librespot.mercury.MercuryRequests; -import xyz.gianlu.librespot.mercury.model.ImageId; -import xyz.gianlu.librespot.mercury.model.PlayableId; +import xyz.gianlu.librespot.metadata.ImageId; +import xyz.gianlu.librespot.metadata.PlayableId; import xyz.gianlu.librespot.player.StateWrapper.NextPlayable; -import xyz.gianlu.librespot.player.codecs.AudioQuality; import xyz.gianlu.librespot.player.codecs.Codec; import xyz.gianlu.librespot.player.contexts.AbsSpotifyContext; -import xyz.gianlu.librespot.player.feeders.AbsChunkedInputStream; +import xyz.gianlu.librespot.player.metrics.NewPlaybackIdEvent; +import xyz.gianlu.librespot.player.metrics.NewSessionIdEvent; +import xyz.gianlu.librespot.player.metrics.PlaybackMetrics; +import xyz.gianlu.librespot.player.metrics.PlayerMetrics; import xyz.gianlu.librespot.player.mixing.AudioSink; import xyz.gianlu.librespot.player.mixing.LineHelper; -import xyz.gianlu.librespot.player.playback.PlayerMetrics; import xyz.gianlu.librespot.player.playback.PlayerSession; +import xyz.gianlu.librespot.player.state.DeviceStateHandler; +import xyz.gianlu.librespot.player.state.DeviceStateHandler.PlayCommandHelper; import javax.sound.sampled.LineUnavailableException; import java.io.Closeable; @@ -49,7 +51,7 @@ public class Player implements Closeable, DeviceStateHandler.Listener, PlayerSes private static final Logger LOGGER = LogManager.getLogger(Player.class); private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(new NameThreadFactory((r) -> "release-line-scheduler-" + r.hashCode())); private final Session session; - private final Configuration conf; + private final PlayerConfiguration conf; private final EventsDispatcher events; private final AudioSink sink; private StateWrapper state; @@ -57,11 +59,13 @@ public class Player implements Closeable, DeviceStateHandler.Listener, PlayerSes private ScheduledFuture releaseLineFuture = null; private Map metrics = new HashMap<>(5); - public Player(@NotNull Player.Configuration conf, @NotNull Session session) { + public Player(@NotNull PlayerConfiguration conf, @NotNull Session session) { this.conf = conf; this.session = session; this.events = new EventsDispatcher(conf); this.sink = new AudioSink(conf, this); + + initState(); } public void addEventsListener(@NotNull EventsListener listener) { @@ -72,16 +76,15 @@ public void removeEventsListener(@NotNull EventsListener listener) { events.listeners.remove(listener); } + private void initState() { + this.state = new StateWrapper(session, this, conf); + state.addListener(this); + } // ================================ // // =========== Commands =========== // // ================================ // - public void initState() { - this.state = new StateWrapper(session); - state.addListener(this); - } - public void volumeUp() { if (state == null) return; setVolume(Math.min(Player.VOLUME_MAX, state.getVolume() + oneVolumeStep())); @@ -93,7 +96,7 @@ public void volumeDown() { } private int oneVolumeStep() { - return Player.VOLUME_MAX / conf.volumeSteps(); + return Player.VOLUME_MAX / conf.volumeSteps; } public void setVolume(int val) { @@ -196,8 +199,8 @@ private void loadSession(@NotNull String sessionId, boolean play, boolean withSk playerSession = null; } - playerSession = new PlayerSession(session, sink, sessionId, this); - session.eventService().newSessionId(sessionId, state); + playerSession = new PlayerSession(session, sink, conf, sessionId, this); + session.eventService().sendEvent(new NewSessionIdEvent(sessionId, state)); loadTrack(play, trans); } @@ -215,7 +218,7 @@ private void loadTrack(boolean play, @NotNull TransitionInfo trans) { String playbackId = playerSession.play(state.getCurrentPlayableOrThrow(), state.getPosition(), trans.startedReason); state.setPlaybackId(playbackId); - session.eventService().newPlaybackId(state, playbackId); + session.eventService().sendEvent(new NewPlaybackIdEvent(state.getSessionId(), playbackId)); if (play) sink.resume(); else sink.pause(false); @@ -387,7 +390,7 @@ private void handlePause() { events.inactiveSession(true); sink.pause(true); - }, conf.releaseLineDelay(), TimeUnit.SECONDS); + }, conf.releaseLineDelay, TimeUnit.SECONDS); } } @@ -418,7 +421,7 @@ private void handleSkipNext(@Nullable JsonObject obj, @NotNull TransitionInfo tr return; } - NextPlayable next = state.nextPlayable(conf); + NextPlayable next = state.nextPlayable(conf.autoplayEnabled); if (next == NextPlayable.AUTOPLAY) { loadAutoplay(); return; @@ -531,7 +534,7 @@ private void endMetrics(String playbackId, @NotNull PlaybackMetrics.Reason reaso pm.endedHow(reason, state.getPlayOrigin().getFeatureIdentifier()); pm.endInterval(when); pm.update(playerMetrics); - session.eventService().trackPlayed(pm, state.device()); + pm.sendEvents(session, state.device()); } @@ -569,7 +572,7 @@ public void sinkError(@NotNull Exception ex) { @Override public void loadingError(@NotNull Exception ex) { - if (ex instanceof ContentRestrictedException) { + if (ex instanceof PlayableContentFeeder.ContentRestrictedException) { LOGGER.error("Can't load track (content restricted).", ex); } else { LOGGER.fatal("Failed loading track.", ex); @@ -597,7 +600,7 @@ public void trackChanged(@NotNull String playbackId, @Nullable TrackOrEpisode me events.trackChanged(); events.metadataAvailable(); - session.eventService().newPlaybackId(state, playbackId); + session.eventService().sendEvent(new NewPlaybackIdEvent(state.getSessionId(), playbackId)); startMetrics(playbackId, startedReason, pos); } @@ -709,7 +712,7 @@ public byte[] currentCoverImage() throws IOException { */ @Override public @Nullable PlayableId nextPlayable() { - NextPlayable next = state.nextPlayable(conf); + NextPlayable next = state.nextPlayable(conf.autoplayEnabled); if (next == NextPlayable.AUTOPLAY) { loadAutoplay(); return null; @@ -747,7 +750,7 @@ public byte[] currentCoverImage() throws IOException { /** * @return The current position of the player or {@code -1} if unavailable (most likely if it's playing an episode). */ - public long time() { + public int time() { try { return playerSession == null ? -1 : playerSession.currentTime(); } catch (Codec.CannotGetTimeException ex) { @@ -777,43 +780,6 @@ public void close() { events.close(); } - public interface Configuration { - @NotNull - AudioQuality preferredQuality(); - - @NotNull - AudioOutput output(); - - @Nullable - File outputPipe(); - - @Nullable - File metadataPipe(); - - boolean preloadEnabled(); - - boolean enableNormalisation(); - - float normalisationPregain(); - - @Nullable - String[] mixerSearchKeywords(); - - boolean logAvailableMixers(); - - int initialVolume(); - - int volumeSteps(); - - boolean autoplayEnabled(); - - int crossfadeDuration(); - - int releaseLineDelay(); - - boolean stopPlaybackOnChunkError(); - } - public interface EventsListener { void onContextChanged(@NotNull String newUri); @@ -928,8 +894,8 @@ private static class MetadataPipe { private final File file; private FileOutputStream out; - MetadataPipe(@NotNull Configuration conf) { - file = conf.metadataPipe(); + MetadataPipe(@NotNull PlayerConfiguration conf) { + file = conf.metadataPipe; } void safeSend(@NotNull String type, @NotNull String code, @Nullable String payload) { @@ -970,7 +936,7 @@ private class EventsDispatcher { private final ExecutorService executorService = Executors.newSingleThreadExecutor(new NameThreadFactory((r) -> "player-events-" + r.hashCode())); private final List listeners = new ArrayList<>(); - EventsDispatcher(@NotNull Configuration conf) { + EventsDispatcher(@NotNull PlayerConfiguration conf) { metadataPipe = new MetadataPipe(conf); } diff --git a/player/src/main/java/xyz/gianlu/librespot/player/PlayerConfiguration.java b/player/src/main/java/xyz/gianlu/librespot/player/PlayerConfiguration.java new file mode 100644 index 00000000..5a35cb03 --- /dev/null +++ b/player/src/main/java/xyz/gianlu/librespot/player/PlayerConfiguration.java @@ -0,0 +1,159 @@ +package xyz.gianlu.librespot.player; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import xyz.gianlu.librespot.player.codecs.AudioQuality; + +import java.io.File; + +/** + * @author devgianlu + */ +public final class PlayerConfiguration { + // Audio + public final AudioQuality preferredQuality; + public final boolean enableNormalisation; + public final float normalisationPregain; + public final boolean autoplayEnabled; + public final int crossfadeDuration; + public final boolean preloadEnabled; + + // Output + public final AudioOutput output; + public final File outputPipe; + public final File metadataPipe; + public final String[] mixerSearchKeywords; + public final boolean logAvailableMixers; + public final int releaseLineDelay; + + // Volume + public final int initialVolume; + public final int volumeSteps; + + private PlayerConfiguration(AudioQuality preferredQuality, boolean enableNormalisation, float normalisationPregain, boolean autoplayEnabled, int crossfadeDuration, boolean preloadEnabled, + AudioOutput output, File outputPipe, File metadataPipe, String[] mixerSearchKeywords, boolean logAvailableMixers, int releaseLineDelay, + int initialVolume, int volumeSteps) { + this.preferredQuality = preferredQuality; + this.enableNormalisation = enableNormalisation; + this.normalisationPregain = normalisationPregain; + this.autoplayEnabled = autoplayEnabled; + this.crossfadeDuration = crossfadeDuration; + this.output = output; + this.outputPipe = outputPipe; + this.metadataPipe = metadataPipe; + this.mixerSearchKeywords = mixerSearchKeywords; + this.logAvailableMixers = logAvailableMixers; + this.releaseLineDelay = releaseLineDelay; + this.initialVolume = initialVolume; + this.volumeSteps = volumeSteps; + this.preloadEnabled = preloadEnabled; + } + + public final static class Builder { + // Audio + private AudioQuality preferredQuality = AudioQuality.NORMAL; + private boolean enableNormalisation = true; + private float normalisationPregain = 3.0f; + private boolean autoplayEnabled = true; + private int crossfadeDuration = 0; + private boolean preloadEnabled = true; + + // Output + private AudioOutput output = AudioOutput.MIXER; + private File outputPipe; + private File metadataPipe; + private String[] mixerSearchKeywords; + private boolean logAvailableMixers = true; + private int releaseLineDelay = 20; + + // Volume + private int initialVolume = Player.VOLUME_MAX; + private int volumeSteps = 64; + + public Builder() { + } + + public Builder setPreferredQuality(AudioQuality preferredQuality) { + this.preferredQuality = preferredQuality; + return this; + } + + public Builder setEnableNormalisation(boolean enableNormalisation) { + this.enableNormalisation = enableNormalisation; + return this; + } + + public Builder setNormalisationPregain(float normalisationPregain) { + this.normalisationPregain = normalisationPregain; + return this; + } + + public Builder setAutoplayEnabled(boolean autoplayEnabled) { + this.autoplayEnabled = autoplayEnabled; + return this; + } + + public Builder setCrossfadeDuration(int crossfadeDuration) { + this.crossfadeDuration = crossfadeDuration; + return this; + } + + public Builder setOutput(AudioOutput output) { + this.output = output; + return this; + } + + public Builder setOutputPipe(File outputPipe) { + this.outputPipe = outputPipe; + return this; + } + + public Builder setMetadataPipe(File metadataPipe) { + this.metadataPipe = metadataPipe; + return this; + } + + public Builder setMixerSearchKeywords(String[] mixerSearchKeywords) { + this.mixerSearchKeywords = mixerSearchKeywords; + return this; + } + + public Builder setLogAvailableMixers(boolean logAvailableMixers) { + this.logAvailableMixers = logAvailableMixers; + return this; + } + + public Builder setReleaseLineDelay(int releaseLineDelay) { + this.releaseLineDelay = releaseLineDelay; + return this; + } + + public Builder setInitialVolume(int initialVolume) { + if (initialVolume < 0 || initialVolume > Player.VOLUME_MAX) + throw new IllegalArgumentException("Invalid volume: " + initialVolume); + + this.initialVolume = initialVolume; + return this; + } + + public Builder setVolumeSteps(int volumeSteps) { + if (volumeSteps < 0 || volumeSteps > Player.VOLUME_MAX) + throw new IllegalArgumentException("Invalid volume steps: " + volumeSteps); + + this.volumeSteps = volumeSteps; + return this; + } + + public Builder setPreloadEnabled(boolean preloadEnabled) { + this.preloadEnabled = preloadEnabled; + return this; + } + + @Contract(value = " -> new", pure = true) + public @NotNull PlayerConfiguration build() { + return new PlayerConfiguration(preferredQuality, enableNormalisation, normalisationPregain, autoplayEnabled, crossfadeDuration, preloadEnabled, + output, outputPipe, metadataPipe, mixerSearchKeywords, logAvailableMixers, releaseLineDelay, + initialVolume, volumeSteps); + } + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java b/player/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java similarity index 97% rename from core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java rename to player/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java index 4ae5ef42..3939a5c4 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/StateWrapper.java @@ -26,19 +26,16 @@ import xyz.gianlu.librespot.common.FisherYatesShuffle; import xyz.gianlu.librespot.common.ProtoUtils; import xyz.gianlu.librespot.common.Utils; -import xyz.gianlu.librespot.connectstate.DeviceStateHandler; -import xyz.gianlu.librespot.connectstate.DeviceStateHandler.PlayCommandHelper; -import xyz.gianlu.librespot.connectstate.RestrictionsManager; -import xyz.gianlu.librespot.connectstate.RestrictionsManager.Action; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.core.TimeProvider; import xyz.gianlu.librespot.dealer.DealerClient; import xyz.gianlu.librespot.mercury.MercuryClient; -import xyz.gianlu.librespot.mercury.model.AlbumId; -import xyz.gianlu.librespot.mercury.model.ArtistId; -import xyz.gianlu.librespot.mercury.model.ImageId; -import xyz.gianlu.librespot.mercury.model.PlayableId; +import xyz.gianlu.librespot.metadata.*; import xyz.gianlu.librespot.player.contexts.AbsSpotifyContext; +import xyz.gianlu.librespot.player.state.DeviceStateHandler; +import xyz.gianlu.librespot.player.state.DeviceStateHandler.PlayCommandHelper; +import xyz.gianlu.librespot.player.state.RestrictionsManager; +import xyz.gianlu.librespot.player.state.RestrictionsManager.Action; import java.io.Closeable; import java.io.IOException; @@ -64,14 +61,16 @@ public class StateWrapper implements DeviceStateHandler.Listener, DealerClient.M private final PlayerState.Builder state; private final Session session; + private final Player player; private final DeviceStateHandler device; private AbsSpotifyContext context; private PagesLoader pages; private TracksKeeper tracksKeeper; - StateWrapper(@NotNull Session session) { + StateWrapper(@NotNull Session session, @NotNull Player player, @NotNull PlayerConfiguration conf) { this.session = session; - this.device = new DeviceStateHandler(session); + this.player = player; + this.device = new DeviceStateHandler(session, conf); this.state = initState(PlayerState.newBuilder()); device.addListener(this); @@ -133,12 +132,12 @@ synchronized void setState(boolean playing, boolean paused, boolean buffering) { setPosition(state.getPositionAsOfTimestamp()); } - boolean isPaused() { + synchronized boolean isPaused() { return state.getIsPlaying() && state.getIsPaused(); } - void setBuffering(boolean buffering) { - setState(state.getIsPlaying(), state.getIsPaused(), buffering); + synchronized void setBuffering(boolean buffering) { + setState(true, state.getIsPaused(), buffering); } private boolean isShufflingContext() { @@ -287,7 +286,7 @@ private void updateRestrictions() { synchronized void updated() { updateRestrictions(); - device.updateState(Connect.PutStateReason.PLAYER_STATE_CHANGED, state.build()); + device.updateState(Connect.PutStateReason.PLAYER_STATE_CHANGED, player.time(), state.build()); } void addListener(@NotNull DeviceStateHandler.Listener listener) { @@ -297,7 +296,7 @@ void addListener(@NotNull DeviceStateHandler.Listener listener) { @Override public synchronized void ready() { state.setIsSystemInitiated(true); - device.updateState(Connect.PutStateReason.NEW_DEVICE, state.build()); + device.updateState(Connect.PutStateReason.NEW_DEVICE, player.time(), state.build()); LOGGER.info("Notified new device (us)!"); } @@ -308,7 +307,7 @@ public void command(@NotNull DeviceStateHandler.Endpoint endpoint, @NotNull Devi @Override public synchronized void volumeChanged() { - device.updateState(Connect.PutStateReason.VOLUME_CHANGED, state.build()); + device.updateState(Connect.PutStateReason.VOLUME_CHANGED, player.time(), state.build()); } @Override @@ -317,7 +316,7 @@ public synchronized void notActive() { initState(state); device.setIsActive(false); - device.updateState(Connect.PutStateReason.BECAME_INACTIVE, state.build()); + device.updateState(Connect.PutStateReason.BECAME_INACTIVE, player.time(), state.build()); LOGGER.info("Notified inactivity!"); } @@ -542,11 +541,11 @@ PlayableId getCurrentPlayableOrThrow() { } @NotNull - NextPlayable nextPlayable(@NotNull Player.Configuration conf) { + NextPlayable nextPlayable(boolean autoplayEnabled) { if (tracksKeeper == null) return NextPlayable.MISSING_TRACKS; try { - return tracksKeeper.nextPlayable(conf); + return tracksKeeper.nextPlayable(autoplayEnabled); } catch (IOException | MercuryClient.MercuryException ex) { LOGGER.error("Failed fetching next playable.", ex); return NextPlayable.MISSING_TRACKS; @@ -1130,7 +1129,7 @@ synchronized void skipTo(@NotNull ContextTrack track) { /** * Figures out what the next {@link PlayableId} should be. This is called directly by the preload function and therefore can return {@code null} as it doesn't account for repeating contexts. - * This will NOT return {@link xyz.gianlu.librespot.mercury.model.UnsupportedId}. + * This will NOT return {@link UnsupportedId}. * * @return The next {@link PlayableId} or {@code null} if there are no more tracks or if repeating the current track */ @@ -1175,7 +1174,7 @@ synchronized PlayableIdWithIndex nextPlayableDoNotSet() throws IOException, Merc } @NotNull - synchronized NextPlayable nextPlayable(@NotNull Player.Configuration conf) throws IOException, MercuryClient.MercuryException { + synchronized NextPlayable nextPlayable(boolean autoplayEnabled) throws IOException, MercuryClient.MercuryException { if (isRepeatingTrack()) { setRepeatingTrack(false); return NextPlayable.OK_REPEAT; @@ -1186,7 +1185,7 @@ synchronized NextPlayable nextPlayable(@NotNull Player.Configuration conf) throw updateState(); if (!shouldPlay(tracks.get(getCurrentTrackIndex()))) - return nextPlayable(conf); + return nextPlayable(autoplayEnabled); return NextPlayable.OK_PLAY; } @@ -1201,7 +1200,7 @@ synchronized NextPlayable nextPlayable(@NotNull Player.Configuration conf) throw if (isRepeatingContext()) { setCurrentTrackIndex(0); } else { - if (conf.autoplayEnabled()) { + if (autoplayEnabled) { return NextPlayable.AUTOPLAY; } else { setCurrentTrackIndex(0); diff --git a/core/src/main/java/xyz/gianlu/librespot/player/TrackOrEpisode.java b/player/src/main/java/xyz/gianlu/librespot/player/TrackOrEpisode.java similarity index 97% rename from core/src/main/java/xyz/gianlu/librespot/player/TrackOrEpisode.java rename to player/src/main/java/xyz/gianlu/librespot/player/TrackOrEpisode.java index a4042ff3..01ae945f 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/TrackOrEpisode.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/TrackOrEpisode.java @@ -5,7 +5,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import xyz.gianlu.librespot.common.Utils; -import xyz.gianlu.librespot.mercury.model.PlayableId; +import xyz.gianlu.librespot.metadata.PlayableId; /** * @author devgianlu diff --git a/core/src/main/java/xyz/gianlu/librespot/player/codecs/AudioQuality.java b/player/src/main/java/xyz/gianlu/librespot/player/codecs/AudioQuality.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/codecs/AudioQuality.java rename to player/src/main/java/xyz/gianlu/librespot/player/codecs/AudioQuality.java diff --git a/core/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java b/player/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java similarity index 85% rename from core/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java rename to player/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java index 9f0f1a81..efab31df 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/codecs/Codec.java @@ -4,10 +4,10 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.player.Player; -import xyz.gianlu.librespot.player.feeders.AbsChunkedInputStream; -import xyz.gianlu.librespot.player.feeders.GeneralAudioStream; -import xyz.gianlu.librespot.player.mixing.AudioSink; +import xyz.gianlu.librespot.audio.AbsChunkedInputStream; +import xyz.gianlu.librespot.audio.GeneralAudioStream; +import xyz.gianlu.librespot.audio.NormalizationData; +import xyz.gianlu.librespot.player.PlayerConfiguration; import javax.sound.sampled.AudioFormat; import java.io.Closeable; @@ -23,19 +23,17 @@ public abstract class Codec implements Closeable { protected final AbsChunkedInputStream audioIn; protected final float normalizationFactor; protected final int duration; - private final AudioSink sink; private final GeneralAudioStream audioFile; protected volatile boolean closed = false; protected int seekZero = 0; private AudioFormat format; - Codec(@NotNull AudioSink sink, @NotNull GeneralAudioStream audioFile, @Nullable NormalizationData normalizationData, @NotNull Player.Configuration conf, int duration) { - this.sink = sink; + Codec(@NotNull GeneralAudioStream audioFile, @Nullable NormalizationData normalizationData, @NotNull PlayerConfiguration conf, int duration) { this.audioIn = audioFile.stream(); this.audioFile = audioFile; this.duration = duration; - if (conf.enableNormalisation()) - this.normalizationFactor = normalizationData != null ? normalizationData.getFactor(conf) : 1; + if (conf.enableNormalisation) + this.normalizationFactor = normalizationData != null ? normalizationData.getFactor(conf.normalisationPregain) : 1; else this.normalizationFactor = 1; } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java b/player/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java similarity index 92% rename from core/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java rename to player/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java index baedbeb6..a3deb622 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/codecs/Mp3Codec.java @@ -3,9 +3,9 @@ import javazoom.jl.decoder.*; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.player.Player; -import xyz.gianlu.librespot.player.feeders.GeneralAudioStream; -import xyz.gianlu.librespot.player.mixing.AudioSink; +import xyz.gianlu.librespot.audio.GeneralAudioStream; +import xyz.gianlu.librespot.audio.NormalizationData; +import xyz.gianlu.librespot.player.PlayerConfiguration; import javax.sound.sampled.AudioFormat; import java.io.IOException; @@ -21,8 +21,8 @@ public class Mp3Codec extends Codec { private final byte[] buffer = new byte[2 * BUFFER_SIZE]; private final Mp3InputStream in; - public Mp3Codec(@NotNull AudioSink sink, @NotNull GeneralAudioStream audioFile, @Nullable NormalizationData normalizationData, Player.@NotNull Configuration conf, int duration) throws IOException, BitstreamException { - super(sink, audioFile, normalizationData, conf, duration); + public Mp3Codec(@NotNull GeneralAudioStream audioFile, @Nullable NormalizationData normalizationData, @NotNull PlayerConfiguration conf, int duration) throws IOException, BitstreamException { + super(audioFile, normalizationData, conf, duration); skipMp3Tags(audioIn); this.in = new Mp3InputStream(audioIn, normalizationFactor); diff --git a/core/src/main/java/xyz/gianlu/librespot/player/codecs/StreamConverter.java b/player/src/main/java/xyz/gianlu/librespot/player/codecs/StreamConverter.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/codecs/StreamConverter.java rename to player/src/main/java/xyz/gianlu/librespot/player/codecs/StreamConverter.java diff --git a/core/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java b/player/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java similarity index 94% rename from core/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java rename to player/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java index 814fc173..84bd796f 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisCodec.java @@ -10,9 +10,9 @@ import com.jcraft.jorbis.Info; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.player.Player; -import xyz.gianlu.librespot.player.feeders.GeneralAudioStream; -import xyz.gianlu.librespot.player.mixing.AudioSink; +import xyz.gianlu.librespot.audio.GeneralAudioStream; +import xyz.gianlu.librespot.audio.NormalizationData; +import xyz.gianlu.librespot.player.PlayerConfiguration; import xyz.gianlu.librespot.player.mixing.LineHelper; import javax.sound.sampled.AudioFormat; @@ -41,8 +41,8 @@ public class VorbisCodec extends Codec { private int index; private long pcm_offset; - public VorbisCodec(@NotNull AudioSink sink, @NotNull GeneralAudioStream audioFile, @Nullable NormalizationData normalizationData, Player.@NotNull Configuration conf, int duration) throws IOException, CodecException, LineHelper.MixerException { - super(sink, audioFile, normalizationData, conf, duration); + public VorbisCodec(@NotNull GeneralAudioStream audioFile, @Nullable NormalizationData normalizationData, @NotNull PlayerConfiguration conf, int duration) throws IOException, CodecException, LineHelper.MixerException { + super(audioFile, normalizationData, conf, duration); this.joggSyncState.init(); this.joggSyncState.buffer(BUFFER_SIZE); diff --git a/core/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisOnlyAudioQuality.java b/player/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisOnlyAudioQuality.java similarity index 89% rename from core/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisOnlyAudioQuality.java rename to player/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisOnlyAudioQuality.java index 7338c1f4..2a69289a 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisOnlyAudioQuality.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/codecs/VorbisOnlyAudioQuality.java @@ -5,6 +5,8 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.audio.format.AudioQualityPicker; +import xyz.gianlu.librespot.audio.format.SuperAudioFormat; import xyz.gianlu.librespot.common.Utils; import java.util.List; @@ -12,7 +14,7 @@ /** * @author Gianlu */ -public class VorbisOnlyAudioQuality implements AudioQualityPreference { +public class VorbisOnlyAudioQuality implements AudioQualityPicker { private static final Logger LOGGER = LogManager.getLogger(VorbisOnlyAudioQuality.class); private final AudioQuality preferred; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/contexts/AbsSpotifyContext.java b/player/src/main/java/xyz/gianlu/librespot/player/contexts/AbsSpotifyContext.java similarity index 96% rename from core/src/main/java/xyz/gianlu/librespot/player/contexts/AbsSpotifyContext.java rename to player/src/main/java/xyz/gianlu/librespot/player/contexts/AbsSpotifyContext.java index ec243406..1ddc618a 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/contexts/AbsSpotifyContext.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/contexts/AbsSpotifyContext.java @@ -1,8 +1,8 @@ package xyz.gianlu.librespot.player.contexts; import org.jetbrains.annotations.NotNull; -import xyz.gianlu.librespot.connectstate.RestrictionsManager; import xyz.gianlu.librespot.core.Session; +import xyz.gianlu.librespot.player.state.RestrictionsManager; import java.util.Objects; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/contexts/GeneralFiniteContext.java b/player/src/main/java/xyz/gianlu/librespot/player/contexts/GeneralFiniteContext.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/contexts/GeneralFiniteContext.java rename to player/src/main/java/xyz/gianlu/librespot/player/contexts/GeneralFiniteContext.java diff --git a/core/src/main/java/xyz/gianlu/librespot/player/contexts/GeneralInfiniteContext.java b/player/src/main/java/xyz/gianlu/librespot/player/contexts/GeneralInfiniteContext.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/contexts/GeneralInfiniteContext.java rename to player/src/main/java/xyz/gianlu/librespot/player/contexts/GeneralInfiniteContext.java diff --git a/core/src/main/java/xyz/gianlu/librespot/player/contexts/SearchContext.java b/player/src/main/java/xyz/gianlu/librespot/player/contexts/SearchContext.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/contexts/SearchContext.java rename to player/src/main/java/xyz/gianlu/librespot/player/contexts/SearchContext.java diff --git a/core/src/main/java/xyz/gianlu/librespot/player/crossfade/CrossfadeController.java b/player/src/main/java/xyz/gianlu/librespot/player/crossfade/CrossfadeController.java similarity index 97% rename from core/src/main/java/xyz/gianlu/librespot/player/crossfade/CrossfadeController.java rename to player/src/main/java/xyz/gianlu/librespot/player/crossfade/CrossfadeController.java index 73cf675e..fc7bf2a4 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/crossfade/CrossfadeController.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/crossfade/CrossfadeController.java @@ -7,9 +7,9 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import xyz.gianlu.librespot.core.EventService.PlaybackMetrics.Reason; -import xyz.gianlu.librespot.mercury.model.PlayableId; -import xyz.gianlu.librespot.player.Player; +import xyz.gianlu.librespot.metadata.PlayableId; +import xyz.gianlu.librespot.player.PlayerConfiguration; +import xyz.gianlu.librespot.player.metrics.PlaybackMetrics.Reason; import java.util.HashMap; import java.util.Map; @@ -28,10 +28,10 @@ public class CrossfadeController { private float lastGain = 1; private int fadeOverlap = 0; - public CrossfadeController(@NotNull String playbackId, int duration, @NotNull Map metadata, @NotNull Player.Configuration conf) { + public CrossfadeController(@NotNull String playbackId, int duration, @NotNull Map metadata, @NotNull PlayerConfiguration conf) { this.playbackId = playbackId; trackDuration = duration; - defaultFadeDuration = conf.crossfadeDuration(); + defaultFadeDuration = conf.crossfadeDuration; String fadeOutUri = metadata.get("audio.fade_out_uri"); fadeOutPlayable = fadeOutUri == null ? null : PlayableId.fromUri(fadeOutUri); diff --git a/core/src/main/java/xyz/gianlu/librespot/player/crossfade/GainInterpolator.java b/player/src/main/java/xyz/gianlu/librespot/player/crossfade/GainInterpolator.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/crossfade/GainInterpolator.java rename to player/src/main/java/xyz/gianlu/librespot/player/crossfade/GainInterpolator.java diff --git a/core/src/main/java/xyz/gianlu/librespot/player/crossfade/LinearDecreasingInterpolator.java b/player/src/main/java/xyz/gianlu/librespot/player/crossfade/LinearDecreasingInterpolator.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/crossfade/LinearDecreasingInterpolator.java rename to player/src/main/java/xyz/gianlu/librespot/player/crossfade/LinearDecreasingInterpolator.java diff --git a/core/src/main/java/xyz/gianlu/librespot/player/crossfade/LinearIncreasingInterpolator.java b/player/src/main/java/xyz/gianlu/librespot/player/crossfade/LinearIncreasingInterpolator.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/crossfade/LinearIncreasingInterpolator.java rename to player/src/main/java/xyz/gianlu/librespot/player/crossfade/LinearIncreasingInterpolator.java diff --git a/core/src/main/java/xyz/gianlu/librespot/player/crossfade/LookupInterpolator.java b/player/src/main/java/xyz/gianlu/librespot/player/crossfade/LookupInterpolator.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/crossfade/LookupInterpolator.java rename to player/src/main/java/xyz/gianlu/librespot/player/crossfade/LookupInterpolator.java diff --git a/player/src/main/java/xyz/gianlu/librespot/player/metrics/CdnRequestEvent.java b/player/src/main/java/xyz/gianlu/librespot/player/metrics/CdnRequestEvent.java new file mode 100644 index 00000000..d4428486 --- /dev/null +++ b/player/src/main/java/xyz/gianlu/librespot/player/metrics/CdnRequestEvent.java @@ -0,0 +1,35 @@ +package xyz.gianlu.librespot.player.metrics; + +import org.jetbrains.annotations.NotNull; +import xyz.gianlu.librespot.core.EventService; + +/** + * @author devgianlu + */ +public final class CdnRequestEvent implements EventService.GenericEvent { + private final PlayerMetrics playerMetrics; + private final String playbackId; + + public CdnRequestEvent(@NotNull PlayerMetrics playerMetrics, @NotNull String playbackId) { + this.playerMetrics = playerMetrics; + this.playbackId = playbackId; + } + + @Override + public EventService.@NotNull EventBuilder build() { + if (playerMetrics.contentMetrics == null) + throw new IllegalStateException(); + + EventService.EventBuilder event = new EventService.EventBuilder(EventService.Type.CDN_REQUEST); + event.append(playerMetrics.contentMetrics.fileId).append(playbackId); + event.append('0').append('0').append('0').append('0').append('0').append('0'); + event.append(String.valueOf(playerMetrics.decodedLength)).append(String.valueOf(playerMetrics.size)); + event.append("music").append("-1").append("-1").append("-1").append("-1.000000"); + event.append("-1").append("-1.000000").append("-1").append("-1").append("-1").append("-1.000000"); + event.append("-1").append("-1").append("-1").append("-1").append("-1.000000").append("-1"); + event.append("0.000000").append("-1.000000").append("").append("").append("unknown"); + event.append('0').append('0').append('0').append('0').append('0'); + event.append("interactive").append('0').append(String.valueOf(playerMetrics.bitrate)).append('0').append('0'); + return event; + } +} diff --git a/player/src/main/java/xyz/gianlu/librespot/player/metrics/NewPlaybackIdEvent.java b/player/src/main/java/xyz/gianlu/librespot/player/metrics/NewPlaybackIdEvent.java new file mode 100644 index 00000000..0dfd0840 --- /dev/null +++ b/player/src/main/java/xyz/gianlu/librespot/player/metrics/NewPlaybackIdEvent.java @@ -0,0 +1,28 @@ +package xyz.gianlu.librespot.player.metrics; + +import org.jetbrains.annotations.NotNull; +import xyz.gianlu.librespot.core.EventService; +import xyz.gianlu.librespot.core.TimeProvider; + +/** + * Event structure for a new playback ID. + * + * @author devgianlu + */ +public final class NewPlaybackIdEvent implements EventService.GenericEvent { + private final String sessionId; + private final String playbackId; + + public NewPlaybackIdEvent(@NotNull String sessionId, @NotNull String playbackId) { + this.sessionId = sessionId; + this.playbackId = playbackId; + } + + @Override + @NotNull + public EventService.EventBuilder build() { + EventService.EventBuilder event = new EventService.EventBuilder(EventService.Type.NEW_PLAYBACK_ID); + event.append(playbackId).append(sessionId).append(String.valueOf(TimeProvider.currentTimeMillis())); + return event; + } +} diff --git a/player/src/main/java/xyz/gianlu/librespot/player/metrics/NewSessionIdEvent.java b/player/src/main/java/xyz/gianlu/librespot/player/metrics/NewSessionIdEvent.java new file mode 100644 index 00000000..7e155083 --- /dev/null +++ b/player/src/main/java/xyz/gianlu/librespot/player/metrics/NewSessionIdEvent.java @@ -0,0 +1,36 @@ +package xyz.gianlu.librespot.player.metrics; + +import org.jetbrains.annotations.NotNull; +import xyz.gianlu.librespot.core.EventService; +import xyz.gianlu.librespot.core.TimeProvider; +import xyz.gianlu.librespot.player.StateWrapper; + +/** + * Event structure for a new session ID. + * + * @author devgianlu + */ +public final class NewSessionIdEvent implements EventService.GenericEvent { + private final String sessionId; + private final StateWrapper state; + + public NewSessionIdEvent(@NotNull String sessionId, @NotNull StateWrapper state) { + this.sessionId = sessionId; + this.state = state; + } + + @Override + @NotNull + public EventService.EventBuilder build() { + String contextUri = state.getContextUri(); + + EventService.EventBuilder event = new EventService.EventBuilder(EventService.Type.NEW_SESSION_ID); + event.append(sessionId); + event.append(contextUri); + event.append(contextUri); + event.append(String.valueOf(TimeProvider.currentTimeMillis())); + event.append("").append(String.valueOf(state.getContextSize())); + event.append(state.getContextUrl()); + return event; + } +} diff --git a/player/src/main/java/xyz/gianlu/librespot/player/metrics/PlaybackMetrics.java b/player/src/main/java/xyz/gianlu/librespot/player/metrics/PlaybackMetrics.java new file mode 100644 index 00000000..30f965a0 --- /dev/null +++ b/player/src/main/java/xyz/gianlu/librespot/player/metrics/PlaybackMetrics.java @@ -0,0 +1,128 @@ +package xyz.gianlu.librespot.player.metrics; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.core.Session; +import xyz.gianlu.librespot.core.TimeProvider; +import xyz.gianlu.librespot.crypto.Packet; +import xyz.gianlu.librespot.metadata.PlayableId; +import xyz.gianlu.librespot.player.StateWrapper; +import xyz.gianlu.librespot.player.state.DeviceStateHandler; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +/** + * @author devgianlu + */ +public class PlaybackMetrics { + private static final Logger LOGGER = LogManager.getLogger(PlaybackMetrics.class); + public final PlayableId id; + final String playbackId; + final String featureVersion; + final String referrerIdentifier; + final String contextUri; + final long timestamp; + private final List intervals = new ArrayList<>(10); + PlayerMetrics player = null; + Reason reasonStart = null; + String sourceStart = null; + Reason reasonEnd = null; + String sourceEnd = null; + private Interval lastInterval = null; + + public PlaybackMetrics(@NotNull PlayableId id, @NotNull String playbackId, @NotNull StateWrapper state) { + this.id = id; + this.playbackId = playbackId; + this.contextUri = state.getContextUri(); + this.featureVersion = state.getPlayOrigin().getFeatureVersion(); + this.referrerIdentifier = state.getPlayOrigin().getReferrerIdentifier(); + this.timestamp = TimeProvider.currentTimeMillis(); + } + + + int firstValue() { + if (intervals.isEmpty()) return 0; + else return intervals.get(0).begin; + } + + int lastValue() { + if (intervals.isEmpty()) return player == null ? 0 : player.duration; + else return intervals.get(intervals.size() - 1).end; + } + + public void startInterval(int begin) { + lastInterval = new Interval(begin); + } + + public void endInterval(int end) { + if (lastInterval == null) return; + if (lastInterval.begin == end) { + lastInterval = null; + return; + } + + lastInterval.end = end; + intervals.add(lastInterval); + lastInterval = null; + } + + public void startedHow(@NotNull Reason reason, @Nullable String origin) { + reasonStart = reason; + sourceStart = origin == null || origin.isEmpty() ? "unknown" : origin; + } + + public void endedHow(@NotNull Reason reason, @Nullable String origin) { + reasonEnd = reason; + sourceEnd = origin == null || origin.isEmpty() ? "unknown" : origin; + } + + public void update(@Nullable PlayerMetrics playerMetrics) { + player = playerMetrics; + } + + public void sendEvents(@NotNull Session session, @NotNull DeviceStateHandler device) { + int when = lastValue(); + + try { + session.send(Packet.Type.TrackEndedTime, ByteBuffer.allocate(5).put((byte) 1).putInt(when).array()); + } catch (IOException ex) { + LOGGER.error("Failed sending TrackEndedTime packet.", ex); + } + + if (player == null || player.contentMetrics == null || device.getLastCommandSentByDeviceId() == null) { + LOGGER.warn("Did not send event because of missing metrics: " + playbackId); + return; + } + + session.eventService().sendEvent(new TrackTransitionEvent(session.deviceId(), device.getLastCommandSentByDeviceId(), this)); + session.eventService().sendEvent(new CdnRequestEvent(player, playbackId)); + session.eventService().sendEvent(new TrackPlayedEvent(playbackId, id, intervals)); + } + + public enum Reason { + TRACK_DONE("trackdone"), TRACK_ERROR("trackerror"), + FORWARD_BTN("fwdbtn"), BACK_BTN("backbtn"), + END_PLAY("endplay"), PLAY_BTN("playbtn"), CLICK_ROW("clickrow"), + LOGOUT("logout"), APP_LOAD("appload"), REMOTE("remote"); + + final String val; + + Reason(@NotNull String val) { + this.val = val; + } + } + + static class Interval { + final int begin; + int end = -1; + + private Interval(int begin) { + this.begin = begin; + } + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerMetrics.java b/player/src/main/java/xyz/gianlu/librespot/player/metrics/PlayerMetrics.java similarity index 84% rename from core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerMetrics.java rename to player/src/main/java/xyz/gianlu/librespot/player/metrics/PlayerMetrics.java index 460faa48..81ea2689 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerMetrics.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/metrics/PlayerMetrics.java @@ -1,11 +1,11 @@ -package xyz.gianlu.librespot.player.playback; +package xyz.gianlu.librespot.player.metrics; import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.audio.PlayableContentFeeder; import xyz.gianlu.librespot.player.codecs.Codec; import xyz.gianlu.librespot.player.codecs.Mp3Codec; import xyz.gianlu.librespot.player.codecs.VorbisCodec; import xyz.gianlu.librespot.player.crossfade.CrossfadeController; -import xyz.gianlu.librespot.player.feeders.PlayableContentFeeder; import javax.sound.sampled.AudioFormat; @@ -23,7 +23,7 @@ public final class PlayerMetrics { public String transition = "none"; public int decryptTime = 0; - PlayerMetrics(@Nullable PlayableContentFeeder.Metrics contentMetrics, @Nullable CrossfadeController crossfade, @Nullable Codec codec) { + public PlayerMetrics(@Nullable PlayableContentFeeder.Metrics contentMetrics, @Nullable CrossfadeController crossfade, @Nullable Codec codec) { this.contentMetrics = contentMetrics; if (codec != null) { diff --git a/player/src/main/java/xyz/gianlu/librespot/player/metrics/TrackPlayedEvent.java b/player/src/main/java/xyz/gianlu/librespot/player/metrics/TrackPlayedEvent.java new file mode 100644 index 00000000..9c9b7433 --- /dev/null +++ b/player/src/main/java/xyz/gianlu/librespot/player/metrics/TrackPlayedEvent.java @@ -0,0 +1,49 @@ +package xyz.gianlu.librespot.player.metrics; + +import org.jetbrains.annotations.NotNull; +import xyz.gianlu.librespot.core.EventService; +import xyz.gianlu.librespot.metadata.PlayableId; + +import java.util.List; + +/** + * @author devgianlu + */ +public final class TrackPlayedEvent implements EventService.GenericEvent { + private final String playbackId; + private final PlayableId content; + private final List intervals; + + public TrackPlayedEvent(@NotNull String playbackId, @NotNull PlayableId content, @NotNull List intervals) { + this.playbackId = playbackId; + this.content = content; + this.intervals = intervals; + } + + @NotNull + private static String intervalsToSend(@NotNull List intervals) { + StringBuilder builder = new StringBuilder(); + builder.append('['); + + boolean first = true; + for (PlaybackMetrics.Interval interval : intervals) { + if (interval.begin == -1 || interval.end == -1) + continue; + + if (!first) builder.append(','); + builder.append('[').append(interval.begin).append(',').append(interval.end).append(']'); + first = false; + } + + builder.append(']'); + return builder.toString(); + } + + @Override + public EventService.@NotNull EventBuilder build() { + EventService.EventBuilder event = new EventService.EventBuilder(EventService.Type.TRACK_PLAYED); + event.append(playbackId).append(content.toSpotifyUri()); + event.append('0').append(intervalsToSend(intervals)); + return event; + } +} diff --git a/player/src/main/java/xyz/gianlu/librespot/player/metrics/TrackTransitionEvent.java b/player/src/main/java/xyz/gianlu/librespot/player/metrics/TrackTransitionEvent.java new file mode 100644 index 00000000..131cddbd --- /dev/null +++ b/player/src/main/java/xyz/gianlu/librespot/player/metrics/TrackTransitionEvent.java @@ -0,0 +1,51 @@ +package xyz.gianlu.librespot.player.metrics; + +import org.jetbrains.annotations.NotNull; +import xyz.gianlu.librespot.core.EventService; + +/** + * @author devgianlu + */ +public final class TrackTransitionEvent implements EventService.GenericEvent { + private static int trackTransitionIncremental = 0; + private final String deviceId; + private final String lastCommandSentByDeviceId; + private final PlaybackMetrics metrics; + + public TrackTransitionEvent(@NotNull String deviceId, @NotNull String lastCommandSentByDeviceId, @NotNull PlaybackMetrics metrics) { + this.deviceId = deviceId; + this.lastCommandSentByDeviceId = lastCommandSentByDeviceId; + this.metrics = metrics; + } + + @Override + public EventService.@NotNull EventBuilder build() { + if (metrics.player.contentMetrics == null) + throw new IllegalStateException(); + + int when = metrics.lastValue(); + EventService.EventBuilder event = new EventService.EventBuilder(EventService.Type.TRACK_TRANSITION); + event.append(String.valueOf(trackTransitionIncremental++)); + event.append(deviceId); + event.append(metrics.playbackId).append("00000000000000000000000000000000"); + event.append(metrics.sourceStart).append(metrics.reasonStart == null ? null : metrics.reasonStart.val); + event.append(metrics.sourceEnd).append(metrics.reasonEnd == null ? null : metrics.reasonEnd.val); + event.append(String.valueOf(metrics.player.decodedLength)).append(String.valueOf(metrics.player.size)); + event.append(String.valueOf(when)).append(String.valueOf(when)); + event.append(String.valueOf(metrics.player.duration)); + event.append(String.valueOf(metrics.player.decryptTime)).append(String.valueOf(metrics.player.fadeOverlap)).append('0').append('0'); + event.append(metrics.firstValue() == 0 ? '0' : '1').append(String.valueOf(metrics.firstValue())); + event.append('0').append("-1").append("context"); + event.append(String.valueOf(metrics.player.contentMetrics.audioKeyTime)).append('0'); + event.append(metrics.player.contentMetrics.preloadedAudioKey ? '1' : '0').append('0').append('0').append('0'); + event.append(String.valueOf(when)).append(String.valueOf(when)); + event.append('0').append(String.valueOf(metrics.player.bitrate)); + event.append(metrics.contextUri).append(metrics.player.encoding); + event.append(metrics.id.hexId()).append(""); + event.append('0').append(String.valueOf(metrics.timestamp)).append('0'); + event.append("context").append(metrics.referrerIdentifier).append(metrics.featureVersion); + event.append("com.spotify").append(metrics.player.transition).append("none"); + event.append(lastCommandSentByDeviceId).append("na").append("none"); + return event; + } +} diff --git a/core/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java b/player/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java similarity index 93% rename from core/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java rename to player/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java index f429a30d..eb8a010d 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/mixing/AudioSink.java @@ -5,6 +5,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import xyz.gianlu.librespot.player.Player; +import xyz.gianlu.librespot.player.PlayerConfiguration; import xyz.gianlu.librespot.player.codecs.Codec; import javax.sound.sampled.AudioFormat; @@ -30,14 +31,14 @@ public final class AudioSink implements Runnable, Closeable { /** * Creates a new sink from the current {@param conf}. Also sets the initial volume. */ - public AudioSink(@NotNull Player.Configuration conf, @NotNull Listener listener) { + public AudioSink(@NotNull PlayerConfiguration conf, @NotNull Listener listener) { this.listener = listener; - switch (conf.output()) { + switch (conf.output) { case MIXER: output = new Output(Output.Type.MIXER, mixing, conf, null, null); break; case PIPE: - File pipe = conf.outputPipe(); + File pipe = conf.outputPipe; if (pipe == null || !pipe.exists() || !pipe.canWrite()) throw new IllegalArgumentException("Invalid pipe file: " + pipe); @@ -47,10 +48,10 @@ public AudioSink(@NotNull Player.Configuration conf, @NotNull Listener listener) output = new Output(Output.Type.STREAM, mixing, conf, null, System.out); break; default: - throw new IllegalArgumentException("Unknown output: " + conf.output()); + throw new IllegalArgumentException("Unknown output: " + conf.output); } - output.setVolume(conf.initialVolume()); + output.setVolume(conf.initialVolume); thread = new Thread(this, "player-audio-sink"); thread.start(); @@ -81,14 +82,10 @@ public void resume() { /** * Pauses the sink and then releases the {@link javax.sound.sampled.Line} if specified by {@param release}. - * - * @return Whether the line was released. */ - public boolean pause(boolean release) { + public void pause(boolean release) { paused = true; - - if (release) return output.releaseLine(); - else return false; + if (release) output.releaseLine(); } /** @@ -168,13 +165,13 @@ public interface Listener { private static class Output implements Closeable { private final File pipe; private final MixingLine mixing; - private final Player.Configuration conf; + private final PlayerConfiguration conf; private final Type type; private SourceDataLine line; private OutputStream out; private int lastVolume = -1; - Output(@NotNull Type type, @NotNull MixingLine mixing, @NotNull Player.Configuration conf, @Nullable File pipe, @Nullable OutputStream out) { + Output(@NotNull Type type, @NotNull MixingLine mixing, @NotNull PlayerConfiguration conf, @Nullable File pipe, @Nullable OutputStream out) { this.conf = conf; this.mixing = mixing; this.type = type; @@ -293,12 +290,11 @@ void setVolume(int volume) { mixing.setGlobalGain(((float) volume) / Player.VOLUME_MAX); } - boolean releaseLine() { - if (line == null) return false; + void releaseLine() { + if (line == null) return; line.close(); line = null; - return true; } enum Type { diff --git a/core/src/main/java/xyz/gianlu/librespot/player/mixing/CircularBuffer.java b/player/src/main/java/xyz/gianlu/librespot/player/mixing/CircularBuffer.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/mixing/CircularBuffer.java rename to player/src/main/java/xyz/gianlu/librespot/player/mixing/CircularBuffer.java diff --git a/core/src/main/java/xyz/gianlu/librespot/player/mixing/GainAwareCircularBuffer.java b/player/src/main/java/xyz/gianlu/librespot/player/mixing/GainAwareCircularBuffer.java similarity index 75% rename from core/src/main/java/xyz/gianlu/librespot/player/mixing/GainAwareCircularBuffer.java rename to player/src/main/java/xyz/gianlu/librespot/player/mixing/GainAwareCircularBuffer.java index f4852f44..59eff06b 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/mixing/GainAwareCircularBuffer.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/mixing/GainAwareCircularBuffer.java @@ -8,6 +8,15 @@ class GainAwareCircularBuffer extends CircularBuffer { super(bufferSize); } + private static void writeToArray(int val, byte[] b, int dest) { + if (val > 32767) val = 32767; + else if (val < -32768) val = -32768; + else if (val < 0) val |= 32768; + + b[dest] = (byte) val; + b[dest + 1] = (byte) (val >>> 8); + } + void readGain(byte[] b, int off, int len, float gain) { if (closed) return; @@ -21,13 +30,7 @@ void readGain(byte[] b, int off, int len, float gain) { for (int i = 0; i < len; i += 2, dest += 2) { int val = (short) ((readInternal() & 0xFF) | ((readInternal() & 0xFF) << 8)); val *= gain; - - if (val > 32767) val = 32767; - else if (val < -32768) val = -32768; - else if (val < 0) val |= 32768; - - b[dest] = (byte) val; - b[dest + 1] = (byte) (val >>> 8); + writeToArray(val, b, dest); } awaitSpace.signal(); @@ -56,13 +59,7 @@ void readMergeGain(byte[] b, int off, int len, float gg, float fg, float sg) { int result = first + second; result *= gg; - - if (result > 32767) result = 32767; - else if (result < -32768) result = -32768; - else if (result < 0) result |= 32768; - - b[dest] = (byte) result; - b[dest + 1] = (byte) (result >>> 8); + writeToArray(result, b, dest); } awaitSpace.signal(); diff --git a/core/src/main/java/xyz/gianlu/librespot/player/mixing/LineHelper.java b/player/src/main/java/xyz/gianlu/librespot/player/mixing/LineHelper.java similarity index 88% rename from core/src/main/java/xyz/gianlu/librespot/player/mixing/LineHelper.java rename to player/src/main/java/xyz/gianlu/librespot/player/mixing/LineHelper.java index f28f1524..f98b9db5 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/mixing/LineHelper.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/mixing/LineHelper.java @@ -5,7 +5,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import xyz.gianlu.librespot.common.Utils; -import xyz.gianlu.librespot.player.Player; +import xyz.gianlu.librespot.player.PlayerConfiguration; import javax.sound.sampled.*; import java.util.ArrayList; @@ -56,11 +56,11 @@ private static Mixer findMixer(@NotNull List mixers, @Nullable String[] k } @NotNull - public static SourceDataLine getLineFor(@NotNull Player.Configuration conf, @NotNull AudioFormat format) throws MixerException, LineUnavailableException { + public static SourceDataLine getLineFor(@NotNull PlayerConfiguration conf, @NotNull AudioFormat format) throws MixerException, LineUnavailableException { DataLine.Info info = new DataLine.Info(SourceDataLine.class, format, AudioSystem.NOT_SPECIFIED); List mixers = findSupportingMixersFor(info); - if (conf.logAvailableMixers()) LOGGER.info("Available mixers: " + Utils.mixersToString(mixers)); - Mixer mixer = findMixer(mixers, conf.mixerSearchKeywords()); + if (conf.logAvailableMixers) LOGGER.info("Available mixers: " + Utils.mixersToString(mixers)); + Mixer mixer = findMixer(mixers, conf.mixerSearchKeywords); return (SourceDataLine) mixer.getLine(info); } diff --git a/core/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java b/player/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java rename to player/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java index d1463a84..8d7a6669 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/mixing/MixingLine.java @@ -158,6 +158,7 @@ public void writeBuffer(@NotNull byte[] b, int off, int len) { } @Override + @SuppressWarnings("DuplicatedCode") public void toggle(boolean enabled, @Nullable AudioFormat format) { if (enabled == fe) return; if (enabled && (fout == null || fout != this)) return; @@ -175,7 +176,6 @@ public void gain(float gain) { } @Override - @SuppressWarnings("DuplicatedCode") public void clear() { if (fout == null || fout != this) return; @@ -205,6 +205,7 @@ public void writeBuffer(@NotNull byte[] b, int off, int len) { } @Override + @SuppressWarnings("DuplicatedCode") public void toggle(boolean enabled, @Nullable AudioFormat format) { if (enabled == se) return; if (enabled && (sout == null || sout != this)) return; @@ -222,7 +223,6 @@ public void gain(float gain) { } @Override - @SuppressWarnings("DuplicatedCode") public void clear() { if (sout == null || sout != this) return; diff --git a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java b/player/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java similarity index 100% rename from core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java rename to player/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueue.java diff --git a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java b/player/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java similarity index 90% rename from core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java rename to player/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java index 593b09a7..5a44829e 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/playback/PlayerQueueEntry.java @@ -5,14 +5,16 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.audio.HaltListener; +import xyz.gianlu.librespot.audio.PlayableContentFeeder; +import xyz.gianlu.librespot.audio.cdn.CdnManager; import xyz.gianlu.librespot.common.Utils; -import xyz.gianlu.librespot.core.EventService.PlaybackMetrics; import xyz.gianlu.librespot.core.Session; import xyz.gianlu.librespot.mercury.MercuryClient; -import xyz.gianlu.librespot.mercury.model.EpisodeId; -import xyz.gianlu.librespot.mercury.model.PlayableId; -import xyz.gianlu.librespot.mercury.model.TrackId; -import xyz.gianlu.librespot.player.ContentRestrictedException; +import xyz.gianlu.librespot.metadata.EpisodeId; +import xyz.gianlu.librespot.metadata.PlayableId; +import xyz.gianlu.librespot.metadata.TrackId; +import xyz.gianlu.librespot.player.PlayerConfiguration; import xyz.gianlu.librespot.player.StateWrapper; import xyz.gianlu.librespot.player.TrackOrEpisode; import xyz.gianlu.librespot.player.codecs.Codec; @@ -20,9 +22,8 @@ import xyz.gianlu.librespot.player.codecs.VorbisCodec; import xyz.gianlu.librespot.player.codecs.VorbisOnlyAudioQuality; import xyz.gianlu.librespot.player.crossfade.CrossfadeController; -import xyz.gianlu.librespot.player.feeders.HaltListener; -import xyz.gianlu.librespot.player.feeders.PlayableContentFeeder; -import xyz.gianlu.librespot.player.feeders.cdn.CdnManager; +import xyz.gianlu.librespot.player.metrics.PlaybackMetrics; +import xyz.gianlu.librespot.player.metrics.PlayerMetrics; import xyz.gianlu.librespot.player.mixing.AudioSink; import xyz.gianlu.librespot.player.mixing.MixingLine; @@ -45,6 +46,7 @@ class PlayerQueueEntry extends PlayerQueue.Entry implements Closeable, Runnable, private static final Logger LOGGER = LogManager.getLogger(PlayerQueueEntry.class); final PlayableId playable; final String playbackId; + private final PlayerConfiguration conf; private final boolean preloaded; private final Listener listener; private final Object playbackLock = new Object(); @@ -62,10 +64,11 @@ class PlayerQueueEntry extends PlayerQueue.Entry implements Closeable, Runnable, private boolean retried = false; private PlayableContentFeeder.Metrics contentMetrics; - PlayerQueueEntry(@NotNull AudioSink sink, @NotNull Session session, @NotNull PlayableId playable, boolean preloaded, @NotNull Listener listener) { + PlayerQueueEntry(@NotNull AudioSink sink, @NotNull Session session, @NotNull PlayerConfiguration conf, @NotNull PlayableId playable, boolean preloaded, @NotNull Listener listener) { this.sink = sink; this.session = session; this.playbackId = StateWrapper.generatePlaybackId(session.random()); + this.conf = conf; this.playable = playable; this.preloaded = preloaded; this.listener = listener; @@ -77,7 +80,7 @@ class PlayerQueueEntry extends PlayerQueue.Entry implements Closeable, Runnable, PlayerQueueEntry retrySelf(boolean preloaded) { if (retried) throw new IllegalStateException(); - PlayerQueueEntry retry = new PlayerQueueEntry(sink, session, playable, preloaded, listener); + PlayerQueueEntry retry = new PlayerQueueEntry(sink, session, conf, playable, preloaded, listener); retry.retried = true; return retry; } @@ -85,10 +88,10 @@ PlayerQueueEntry retrySelf(boolean preloaded) { /** * Loads the content described by this entry. * - * @throws ContentRestrictedException If the content cannot be retrieved because of restrictions (this condition won't change with a retry). + * @throws PlayableContentFeeder.ContentRestrictedException If the content cannot be retrieved because of restrictions (this condition won't change with a retry). */ - private void load(boolean preload) throws IOException, Codec.CodecException, MercuryClient.MercuryException, CdnManager.CdnException, ContentRestrictedException { - PlayableContentFeeder.LoadedStream stream = session.contentFeeder().load(playable, new VorbisOnlyAudioQuality(session.conf().preferredQuality()), preload, this); + private void load(boolean preload) throws IOException, Codec.CodecException, MercuryClient.MercuryException, CdnManager.CdnException, PlayableContentFeeder.ContentRestrictedException { + PlayableContentFeeder.LoadedStream stream = session.contentFeeder().load(playable, new VorbisOnlyAudioQuality(conf.preferredQuality), preload, this); metadata = new TrackOrEpisode(stream.track, stream.episode); contentMetrics = stream.metrics; @@ -99,17 +102,17 @@ private void load(boolean preload) throws IOException, Codec.CodecException, Mer Utils.artistsToString(stream.track.getArtistList()), playable.toSpotifyUri(), playbackId); } - crossfade = new CrossfadeController(playbackId, metadata.duration(), listener.metadataFor(playable).orElse(Collections.emptyMap()), session.conf()); - if (crossfade.hasAnyFadeOut() || session.conf().preloadEnabled()) + crossfade = new CrossfadeController(playbackId, metadata.duration(), listener.metadataFor(playable).orElse(Collections.emptyMap()), conf); + if (crossfade.hasAnyFadeOut() || conf.preloadEnabled) notifyInstant(INSTANT_PRELOAD, (int) (crossfade.fadeOutStartTimeMin() - TimeUnit.SECONDS.toMillis(20))); switch (stream.in.codec()) { case VORBIS: - codec = new VorbisCodec(sink, stream.in, stream.normalizationData, session.conf(), metadata.duration()); + codec = new VorbisCodec(stream.in, stream.normalizationData, conf, metadata.duration()); break; case MP3: try { - codec = new Mp3Codec(sink, stream.in, stream.normalizationData, session.conf(), metadata.duration()); + codec = new Mp3Codec(stream.in, stream.normalizationData, conf, metadata.duration()); } catch (BitstreamException ex) { throw new IOException(ex); } @@ -246,7 +249,7 @@ public void run() { try { load(preloaded); - } catch (IOException | ContentRestrictedException | CdnManager.CdnException | MercuryClient.MercuryException | Codec.CodecException ex) { + } catch (IOException | PlayableContentFeeder.ContentRestrictedException | CdnManager.CdnException | MercuryClient.MercuryException | Codec.CodecException ex) { close(); listener.loadingError(this, ex, retried); LOGGER.trace("{} terminated at loading.", this, ex); diff --git a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java b/player/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java similarity index 95% rename from core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java rename to player/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java index 745ee931..cce9dd53 100644 --- a/core/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/playback/PlayerSession.java @@ -5,14 +5,16 @@ import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import xyz.gianlu.librespot.audio.PlayableContentFeeder; import xyz.gianlu.librespot.common.NameThreadFactory; -import xyz.gianlu.librespot.core.EventService.PlaybackMetrics.Reason; import xyz.gianlu.librespot.core.Session; -import xyz.gianlu.librespot.mercury.model.PlayableId; -import xyz.gianlu.librespot.player.ContentRestrictedException; +import xyz.gianlu.librespot.metadata.PlayableId; +import xyz.gianlu.librespot.player.PlayerConfiguration; import xyz.gianlu.librespot.player.TrackOrEpisode; import xyz.gianlu.librespot.player.codecs.Codec; import xyz.gianlu.librespot.player.crossfade.CrossfadeController; +import xyz.gianlu.librespot.player.metrics.PlaybackMetrics.Reason; +import xyz.gianlu.librespot.player.metrics.PlayerMetrics; import xyz.gianlu.librespot.player.mixing.AudioSink; import xyz.gianlu.librespot.player.mixing.MixingLine; @@ -33,6 +35,7 @@ public class PlayerSession implements Closeable, PlayerQueueEntry.Listener { private final ExecutorService executorService = Executors.newCachedThreadPool(new NameThreadFactory((r) -> "player-session-" + r.hashCode())); private final Session session; private final AudioSink sink; + private final PlayerConfiguration conf; private final String sessionId; private final Listener listener; private final PlayerQueue queue; @@ -40,16 +43,16 @@ public class PlayerSession implements Closeable, PlayerQueueEntry.Listener { private Reason lastPlayReason = null; private volatile boolean closed = false; - public PlayerSession(@NotNull Session session, @NotNull AudioSink sink, @NotNull String sessionId, @NotNull Listener listener) { + public PlayerSession(@NotNull Session session, @NotNull AudioSink sink, @NotNull PlayerConfiguration conf, @NotNull String sessionId, @NotNull Listener listener) { this.session = session; this.sink = sink; + this.conf = conf; this.sessionId = sessionId; this.listener = listener; this.queue = new PlayerQueue(); LOGGER.info("Created new session. {id: {}}", sessionId); sink.clearOutputs(); - add(listener.currentPlayable(), false); } /** @@ -58,7 +61,7 @@ public PlayerSession(@NotNull Session session, @NotNull AudioSink sink, @NotNull * @param playable The content for the new entry */ private void add(@NotNull PlayableId playable, boolean preloaded) { - PlayerQueueEntry entry = new PlayerQueueEntry(sink, session, playable, preloaded, this); + PlayerQueueEntry entry = new PlayerQueueEntry(sink, session, conf, playable, preloaded, this); queue.add(entry); if (queue.next() == entry) { PlayerQueueEntry head = queue.head(); @@ -147,7 +150,7 @@ public void startedLoading(@NotNull PlayerQueueEntry entry) { @Override public void loadingError(@NotNull PlayerQueueEntry entry, @NotNull Exception ex, boolean retried) { if (entry == queue.head()) { - if (ex instanceof ContentRestrictedException) { + if (ex instanceof PlayableContentFeeder.ContentRestrictedException) { advance(Reason.TRACK_ERROR); } else if (!retried) { PlayerQueueEntry newEntry = entry.retrySelf(false); @@ -160,7 +163,7 @@ public void loadingError(@NotNull PlayerQueueEntry entry, @NotNull Exception ex, listener.loadingError(ex); } else if (entry == queue.next()) { - if (!(ex instanceof ContentRestrictedException) && !retried) { + if (!(ex instanceof PlayableContentFeeder.ContentRestrictedException) && !retried) { PlayerQueueEntry newEntry = entry.retrySelf(true); executorService.execute(() -> queue.swap(entry, newEntry)); return; @@ -321,7 +324,7 @@ public TrackOrEpisode currentMetadata() { * @return The time for the current head or {@code -1} if not available. * @throws Codec.CannotGetTimeException If the head is available, but time cannot be retrieved */ - public long currentTime() throws Codec.CannotGetTimeException { + public int currentTime() throws Codec.CannotGetTimeException { if (queue.head() == null) return -1; else return queue.head().getTime(); } diff --git a/core/src/main/java/xyz/gianlu/librespot/connectstate/DeviceStateHandler.java b/player/src/main/java/xyz/gianlu/librespot/player/state/DeviceStateHandler.java similarity index 97% rename from core/src/main/java/xyz/gianlu/librespot/connectstate/DeviceStateHandler.java rename to player/src/main/java/xyz/gianlu/librespot/player/state/DeviceStateHandler.java index 1eca7b82..b6b193d7 100644 --- a/core/src/main/java/xyz/gianlu/librespot/connectstate/DeviceStateHandler.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/state/DeviceStateHandler.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.connectstate; +package xyz.gianlu.librespot.player.state; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -22,6 +22,7 @@ import xyz.gianlu.librespot.dealer.DealerClient; import xyz.gianlu.librespot.dealer.DealerClient.RequestResult; import xyz.gianlu.librespot.mercury.MercuryClient; +import xyz.gianlu.librespot.player.PlayerConfiguration; import java.io.Closeable; import java.io.IOException; @@ -50,9 +51,9 @@ public final class DeviceStateHandler implements Closeable, DealerClient.Message private final AsyncWorker putStateWorker; private volatile String connectionId = null; - public DeviceStateHandler(@NotNull Session session) { + public DeviceStateHandler(@NotNull Session session, @NotNull PlayerConfiguration conf) { this.session = session; - this.deviceInfo = initializeDeviceInfo(session); + this.deviceInfo = initializeDeviceInfo(session, conf); this.putStateWorker = new AsyncWorker<>("put-state-worker", this::putConnectState); this.putState = Connect.PutStateRequest.newBuilder() .setMemberType(Connect.MemberType.CONNECT_STATE) @@ -65,10 +66,10 @@ public DeviceStateHandler(@NotNull Session session) { } @NotNull - private static Connect.DeviceInfo.Builder initializeDeviceInfo(@NotNull Session session) { + private static Connect.DeviceInfo.Builder initializeDeviceInfo(@NotNull Session session, @NotNull PlayerConfiguration conf) { return Connect.DeviceInfo.newBuilder() .setCanPlay(true) - .setVolume(session.conf().initialVolume()) + .setVolume(conf.initialVolume) .setName(session.deviceName()) .setDeviceId(session.deviceId()) .setDeviceType(session.deviceType()) @@ -78,7 +79,7 @@ private static Connect.DeviceInfo.Builder initializeDeviceInfo(@NotNull Session .setCanBePlayer(true).setGaiaEqConnectId(true).setSupportsLogout(true) .setIsObservable(true).setCommandAcks(true).setSupportsRename(false) .setSupportsPlaylistV2(true).setIsControllable(true).setSupportsTransferCommand(true) - .setSupportsCommandRequest(true).setVolumeSteps(session.conf().volumeSteps()) + .setSupportsCommandRequest(true).setVolumeSteps(conf.volumeSteps) .setSupportsGzipPushes(true).setNeedsFullPlayerState(false) .addSupportedTypes("audio/episode") .addSupportedTypes("audio/track") @@ -201,10 +202,9 @@ public synchronized void setIsActive(boolean active) { } } - public synchronized void updateState(@NotNull Connect.PutStateReason reason, @NotNull Player.PlayerState state) { + public synchronized void updateState(@NotNull Connect.PutStateReason reason, int playerTime, @NotNull Player.PlayerState state) { if (connectionId == null) throw new IllegalStateException(); - long playerTime = session.player().time(); if (playerTime == -1) putState.clearHasBeenPlayingForMs(); else putState.setHasBeenPlayingForMs(playerTime); diff --git a/core/src/main/java/xyz/gianlu/librespot/connectstate/RestrictionsManager.java b/player/src/main/java/xyz/gianlu/librespot/player/state/RestrictionsManager.java similarity index 99% rename from core/src/main/java/xyz/gianlu/librespot/connectstate/RestrictionsManager.java rename to player/src/main/java/xyz/gianlu/librespot/player/state/RestrictionsManager.java index b085bb30..e73f0867 100644 --- a/core/src/main/java/xyz/gianlu/librespot/connectstate/RestrictionsManager.java +++ b/player/src/main/java/xyz/gianlu/librespot/player/state/RestrictionsManager.java @@ -1,4 +1,4 @@ -package xyz.gianlu.librespot.connectstate; +package xyz.gianlu.librespot.player.state; import com.spotify.connectstate.Player; import org.jetbrains.annotations.NotNull; diff --git a/core/src/main/resources/default.toml b/player/src/main/resources/default.toml similarity index 92% rename from core/src/main/resources/default.toml rename to player/src/main/resources/default.toml index 4f2e1b75..76e3318f 100644 --- a/core/src/main/resources/default.toml +++ b/player/src/main/resources/default.toml @@ -5,12 +5,12 @@ preferredLocale = "en" ### Preferred locale ### logLevel = "TRACE" ### Log level (OFF, FATAL, ERROR, WARN, INFO, DEBUG, TRACE, ALL) ### [auth] ### Authentication ### -strategy = "ZEROCONF" # Strategy (USER_PASS, ZEROCONF, BLOB, FACEBOOK) +strategy = "ZEROCONF" # Strategy (USER_PASS, ZEROCONF, BLOB, FACEBOOK, STORED) username = "" # Spotify username (BLOB, USER_PASS only) password = "" # Spotify password (USER_PASS only) -blob = "" # Spotify authentication blob (BLOB only) +blob = "" # Spotify authentication blob Base64-encoded (BLOB only) storeCredentials = false # Whether to store reusable credentials on disk (not a plain password) -credentialsFile = "" # Credentials file (JSON) +credentialsFile = "credentials.json" # Credentials file (JSON) [zeroconf] ### Zeroconf ### listenPort = -1 # Listen on this TCP port (`-1` for random) @@ -42,7 +42,7 @@ crossfadeDuration = 0 # Crossfade overlap time (in milliseconds) output = "MIXER" # Audio output device (MIXER, PIPE, STDOUT) releaseLineDelay = 20 # Release mixer line after set delay (in seconds) pipe = "" # Output raw (signed) PCM to this file (`player.output` must be PIPE) -stopPlaybackOnChunkError = false # Whether the playback should be stopped when the current chunk cannot be downloaded +retryOnChunkError = true # Whether the player should retry fetching a chuck if it fails metadataPipe = "" # Output metadata in Shairport Sync format (https://github.com/mikebrady/shairport-sync-metadata-reader) [api] ### API ### diff --git a/core/src/main/resources/log4j2.xml b/player/src/main/resources/log4j2.xml similarity index 100% rename from core/src/main/resources/log4j2.xml rename to player/src/main/resources/log4j2.xml diff --git a/core/src/test/java/xyz/gianlu/librespot/CircularBufferTest.java b/player/src/test/java/xyz/gianlu/librespot/CircularBufferTest.java similarity index 100% rename from core/src/test/java/xyz/gianlu/librespot/CircularBufferTest.java rename to player/src/test/java/xyz/gianlu/librespot/CircularBufferTest.java diff --git a/core/src/test/java/xyz/gianlu/librespot/player/crossfade/InterpolatorTest.java b/player/src/test/java/xyz/gianlu/librespot/player/crossfade/InterpolatorTest.java similarity index 100% rename from core/src/test/java/xyz/gianlu/librespot/player/crossfade/InterpolatorTest.java rename to player/src/test/java/xyz/gianlu/librespot/player/crossfade/InterpolatorTest.java diff --git a/pom.xml b/pom.xml index 7041adc9..14b0e4dd 100644 --- a/pom.xml +++ b/pom.xml @@ -38,12 +38,12 @@ 1.8 1.8 2.8.6 - 3.11.4 + 3.12.2 - common - core + lib + player api @@ -59,12 +59,12 @@ org.apache.logging.log4j log4j-core - 2.13.2 + 2.13.3 org.apache.logging.log4j log4j-slf4j-impl - 2.13.2 + 2.13.3 runtime