diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index c0a85cee1..674c496a6 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -173,7 +173,7 @@ Server emitted an event. See the client implementation below. ```json { "op": "event", - ... + "type": "..." } ``` @@ -185,7 +185,7 @@ Server emitted an event. See the client implementation below. * 2. TrackExceptionEvent * 3. TrackStuckEvent *

- * The remaining are caused by the client + * The remaining lavaplayer events are caused by client actions, and are therefore not forwarded via WS. */ private void handleEvent(JSONObject json) throws IOException { LavalinkPlayer player = (LavalinkPlayer) lavalink.getPlayer(json.getString("guildId")); @@ -221,6 +221,22 @@ private void handleEvent(JSONObject json) throws IOException { See also: [AudioTrackEndReason.java](https://github.com/sedmelluq/lavaplayer/blob/master/main/src/main/java/com/sedmelluq/discord/lavaplayer/track/AudioTrackEndReason.java) +Additionally there is also the `WebSocketClosedEvent`, which signals when an audio web socket (to Discord) is closed. +This can happen for various reasons (normal and abnormal), e.g when using an expired voice server update. +4xxx codes are usually bad. +See the [Discord docs](https://discordapp.com/developers/docs/topics/opcodes-and-status-codes#voice-voice-close-event-codes). + +```json +{ + "op": "event", + "type": "WebSocketClosedEvent", + "guildId": "...", + "code": 4006, + "reason": "Your session is no longer valid.", + "byRemote": true +} +``` + ### REST API The REST api is used to resolve audio tracks for use with the `play` op. ``` diff --git a/LavalinkServer/application.yml.example b/LavalinkServer/application.yml.example index f475eeba7..f7152c23d 100644 --- a/LavalinkServer/application.yml.example +++ b/LavalinkServer/application.yml.example @@ -1,4 +1,4 @@ -server: # REST server +server: # REST and WS server port: 2333 address: 0.0.0.0 spring: @@ -7,9 +7,6 @@ spring: lavalink: server: password: "youshallnotpass" - ws: - port: 80 - host: 0.0.0.0 sources: youtube: true bandcamp: true diff --git a/LavalinkServer/build.gradle b/LavalinkServer/build.gradle index 8f99df7c5..dc1b296c6 100644 --- a/LavalinkServer/build.gradle +++ b/LavalinkServer/build.gradle @@ -36,16 +36,22 @@ test { } dependencies { + compile group: 'space.npstr', name: 'Magma', version: magmaVersion compile group: 'com.sedmelluq', name: 'lavaplayer', version: lavaplayerVersion - compile group: 'com.github.DV8FromTheWorld', name: 'JDA-Audio', version: jdaAudioVersion - compile group: 'com.github.FredBoat.jda-nas', name: 'jda-nas', version: jdaNasVersion + compile group: 'com.sedmelluq', name: 'jda-nas', version: jdaNasVersion + compile group: 'com.github.shredder121', name: 'jda-async-packetprovider', version: jappVersion - compile group: 'org.java-websocket', name: 'Java-WebSocket', version: javaWebSocketVersion + //required by japp + compile group: 'org.apache.commons', name: 'commons-lang3', version: commonsLangVersion + compile group: 'org.springframework', name: 'spring-websocket', version: springWebSocketVersion compile group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion compile group: 'io.sentry', name: 'sentry-logback', version: sentryLogbackVersion compile group: 'com.github.oshi', name: 'oshi-core', version: oshiVersion compile group: 'org.json', name: 'json', version: jsonOrgVersion - compile group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: springBootVersion + compile(group: 'org.springframework.boot', name: 'spring-boot-starter-web', version: springBootVersion) { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' + } + compile group: 'org.springframework.boot', name: 'spring-boot-starter-undertow', version: springBootVersion compileOnly group: 'com.github.spotbugs', name: 'spotbugs-annotations', version: spotbugsAnnotationsVersion compile group: 'io.prometheus', name: 'simpleclient', version: prometheusVersion diff --git a/LavalinkServer/src/main/java/lavalink/server/Launcher.java b/LavalinkServer/src/main/java/lavalink/server/Launcher.java index f00ca914d..5b4ba9a6d 100644 --- a/LavalinkServer/src/main/java/lavalink/server/Launcher.java +++ b/LavalinkServer/src/main/java/lavalink/server/Launcher.java @@ -25,9 +25,6 @@ import com.sedmelluq.discord.lavaplayer.tools.PlayerLibrary; import lavalink.server.info.AppInfo; import lavalink.server.info.GitRepoState; -import lavalink.server.io.SocketServer; -import lavalink.server.util.SimpleLogToSLF4JAdapter; -import net.dv8tion.jda.utils.SimpleLog; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.SpringApplication; @@ -35,14 +32,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent; import org.springframework.boot.context.event.ApplicationFailedEvent; -import org.springframework.context.annotation.ComponentScan; import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; @SpringBootApplication -@ComponentScan public class Launcher { private static final Logger log = LoggerFactory.getLogger(Launcher.class); @@ -76,20 +71,6 @@ public static void main(String[] args) { sa.run(args); } - public Launcher(SocketServer socketServer) { - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - log.info("Shutdown hook triggered"); - try { - socketServer.stop(30); - } catch (InterruptedException e) { - log.warn("Interrupted while stopping socket server", e); - } - }, "shutdown hook")); - - SimpleLog.LEVEL = SimpleLog.Level.OFF; - SimpleLog.addListener(new SimpleLogToSLF4JAdapter()); - } - private static String getVersionInfo() { AppInfo appInfo = new AppInfo(); GitRepoState gitRepoState = new GitRepoState(); diff --git a/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.java b/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.java index 838619006..105de19fd 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.java +++ b/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.java @@ -1,31 +1,29 @@ package lavalink.server.config; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; +import lavalink.server.io.HandshakeInterceptorImpl; +import lavalink.server.io.SocketServer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; -/** - * Created by napster on 05.03.18. - */ -@ConfigurationProperties(prefix = "lavalink.server.ws") -@Component -public class WebsocketConfig { +@Configuration +@EnableWebSocket +public class WebsocketConfig implements WebSocketConfigurer { - private int port = 80; - private String host = "0.0.0.0"; + private final SocketServer server; + private final HandshakeInterceptorImpl handshakeInterceptor; - public int getPort() { - return port; + @Autowired + public WebsocketConfig(SocketServer server, HandshakeInterceptorImpl handshakeInterceptor) { + this.server = server; + this.handshakeInterceptor = handshakeInterceptor; } - public void setPort(int port) { - this.port = port; - } - - public String getHost() { - return host; - } - - public void setHost(String host) { - this.host = host; + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(server, "/") + .addInterceptors(handshakeInterceptor); } } diff --git a/LavalinkServer/src/main/java/lavalink/server/info/AppInfo.java b/LavalinkServer/src/main/java/lavalink/server/info/AppInfo.java index 0143e13de..1d61c257a 100644 --- a/LavalinkServer/src/main/java/lavalink/server/info/AppInfo.java +++ b/LavalinkServer/src/main/java/lavalink/server/info/AppInfo.java @@ -36,7 +36,11 @@ public AppInfo() { this.groupId = prop.getProperty("groupId"); this.artifactId = prop.getProperty("artifactId"); this.buildNumber = prop.getProperty("buildNumber"); - this.buildTime = Long.parseLong(prop.getProperty("buildTime")); + long bTime = -1L; + try { + bTime = Long.parseLong(prop.getProperty("buildTime")); + } catch (NumberFormatException ignored) { } + this.buildTime = bTime; } public String getVersion() { diff --git a/LavalinkServer/src/main/java/lavalink/server/info/GitRepoState.java b/LavalinkServer/src/main/java/lavalink/server/info/GitRepoState.java index 0c1dcdf01..5753e4d9f 100644 --- a/LavalinkServer/src/main/java/lavalink/server/info/GitRepoState.java +++ b/LavalinkServer/src/main/java/lavalink/server/info/GitRepoState.java @@ -50,7 +50,7 @@ public GitRepoState() { this.commitMessageFull = String.valueOf(properties.getOrDefault("git.commit.message.full", "")); this.commitMessageShort = String.valueOf(properties.getOrDefault("git.commit.message.short", "")); final String time = String.valueOf(properties.get("git.commit.time")); - if (time == null) { + if (time == null || time.equals("null")) { this.commitTime = 0; } else { // https://github.com/n0mer/gradle-git-properties/issues/71 diff --git a/LavalinkServer/src/main/java/lavalink/server/io/ConnectionManagerImpl.java b/LavalinkServer/src/main/java/lavalink/server/io/ConnectionManagerImpl.java deleted file mode 100644 index 22c101672..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/io/ConnectionManagerImpl.java +++ /dev/null @@ -1,23 +0,0 @@ -package lavalink.server.io; - -import net.dv8tion.jda.manager.ConnectionManager; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * Created by Repulser - * https://github.com/Repulser - */ -public class ConnectionManagerImpl implements ConnectionManager { - private static final Logger log = LoggerFactory.getLogger(ConnectionManagerImpl.class); - - @Override - public void removeAudioConnection(String guildId) { - } - - @Override - public void queueAudioConnect(String guildId, String channelId) { - log.warn("queueAudioConnect was requested, this shouldn't happen, guildId:" + guildId + " channelId:" + channelId, new RuntimeException()); - } - -} diff --git a/LavalinkServer/src/main/java/lavalink/server/io/CoreClientImpl.java b/LavalinkServer/src/main/java/lavalink/server/io/CoreClientImpl.java deleted file mode 100644 index 1cb045e46..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/io/CoreClientImpl.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2017 Frederik Ar. Mikkelsen & NoobLance - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package lavalink.server.io; - -import net.dv8tion.jda.CoreClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class CoreClientImpl implements CoreClient { - - private static final Logger log = LoggerFactory.getLogger(CoreClientImpl.class); - - @Override - public void sendWS(String message) { - } - - @Override - public boolean isConnected() { - log.warn("isConnected was requested, this shouldn't happen", new RuntimeException()); - return true; - } - - @Override - public boolean inGuild(String guildId) { - log.warn("inGuild was requested, this shouldn't happen, guildId:" + guildId, new RuntimeException()); - return true; - } - - @Override - public boolean voiceChannelExists(String guildId, String channelId) { - log.warn("voiceChannelExists was requested, this shouldn't happen, guildId:" + guildId + " channelId:" + channelId, new RuntimeException()); - return true; - } - - @Override - public boolean hasPermissionInChannel(String guildId, String channelId, long l) { - log.warn("hasPermissionInChannel was requested, this shouldn't happen, guildId:" + guildId + " channelId:" + channelId + " l:" + l, new RuntimeException()); - return true; - } - -} diff --git a/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.java b/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.java new file mode 100644 index 000000000..aa0b80341 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.java @@ -0,0 +1,53 @@ +package lavalink.server.io; + +import lavalink.server.config.ServerConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.stereotype.Controller; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Map; +import java.util.Objects; + +@Controller +public class HandshakeInterceptorImpl implements HandshakeInterceptor { + + private static final Logger log = LoggerFactory.getLogger(HandshakeInterceptorImpl.class); + private final ServerConfig serverConfig; + + @Autowired + public HandshakeInterceptorImpl(ServerConfig serverConfig) { + this.serverConfig = serverConfig; + } + + /** + * Checks credentials and sets the Lavalink version header + * + * @return true if authenticated + */ + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, + Map attributes) { + response.getHeaders().add("Lavalink-Major-Version", "3"); + + String password = request.getHeaders().getFirst("Authorization"); + boolean matches = Objects.equals(password, serverConfig.getPassword()); + + if (matches) { + log.info("Incoming connection from " + request.getRemoteAddress()); + } else { + log.error("Authentication failed from " + request.getRemoteAddress()); + } + + return matches; + } + + // No action required + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, + Exception exception) {} +} diff --git a/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.java b/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.java index da276ef14..bfa2a3b43 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.java +++ b/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.java @@ -22,22 +22,19 @@ package lavalink.server.io; -import com.github.shredder121.asyncaudio.jdaaudio.AsyncPacketProviderFactory; -import com.sedmelluq.discord.lavaplayer.jdaudp.NativeAudioSendFactory; import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; -import lavalink.server.config.AudioSendFactoryConfiguration; -import lavalink.server.config.ServerConfig; import lavalink.server.player.Player; -import lavalink.server.util.Util; -import net.dv8tion.jda.Core; -import net.dv8tion.jda.audio.factory.IAudioSendFactory; -import net.dv8tion.jda.manager.AudioManager; -import net.dv8tion.jda.manager.ConnectionManagerBuilder; -import org.java_websocket.WebSocket; +import lavalink.server.util.Ws; +import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.web.socket.WebSocketSession; +import space.npstr.magma.MagmaApi; +import space.npstr.magma.MagmaMember; +import space.npstr.magma.Member; +import space.npstr.magma.events.api.MagmaEvent; +import space.npstr.magma.events.api.WebSocketClosed; -import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -52,26 +49,21 @@ public class SocketContext { private static final Logger log = LoggerFactory.getLogger(SocketContext.class); private final AudioPlayerManager audioPlayerManager; - private final ServerConfig serverConfig; - private final WebSocket socket; - private final AudioSendFactoryConfiguration audioSendFactoryConfiguration; + private final WebSocketSession socket; private String userId; - private int shardCount; - private final Map cores = new HashMap<>(); + private final MagmaApi magmaApi; + //guildId <-> Player private final Map players = new ConcurrentHashMap<>(); private ScheduledExecutorService statsExecutor; public final ScheduledExecutorService playerUpdateService; - private final ConcurrentHashMap sendFactories = new ConcurrentHashMap<>(); - SocketContext(Supplier audioPlayerManagerSupplier, ServerConfig serverConfig, WebSocket socket, - AudioSendFactoryConfiguration audioSendFactoryConfiguration, SocketServer socketServer, - String userId, int shardCount) { + SocketContext(Supplier audioPlayerManagerSupplier, WebSocketSession socket, + SocketServer socketServer, String userId) { this.audioPlayerManager = audioPlayerManagerSupplier.get(); - this.serverConfig = serverConfig; this.socket = socket; - this.audioSendFactoryConfiguration = audioSendFactoryConfiguration; this.userId = userId; - this.shardCount = shardCount; + this.magmaApi = MagmaApi.of(socketServer::getAudioSendFactory); + magmaApi.getEventStream().subscribe(this::handleMagmaEvent); statsExecutor = Executors.newSingleThreadScheduledExecutor(); statsExecutor.scheduleAtFixedRate(new StatsTask(this, socketServer), 0, 1, TimeUnit.MINUTES); @@ -84,15 +76,8 @@ public class SocketContext { }); } - Core getCore(int shardId) { - return cores.computeIfAbsent(shardId, - __ -> { - if (audioSendFactoryConfiguration.isNasSupported()) - return new Core(userId, new CoreClientImpl(), core -> new ConnectionManagerImpl(), getAudioSendFactory(shardId)); - else - return new Core(userId, new CoreClientImpl(), (ConnectionManagerBuilder) core -> new ConnectionManagerImpl()); - } - ); + public String getUserId() { + return userId; } Player getPlayer(String guildId) { @@ -101,11 +86,7 @@ Player getPlayer(String guildId) { ); } - int getShardCount() { - return shardCount; - } - - public WebSocket getSocket() { + public WebSocketSession getSession() { return socket; } @@ -121,37 +102,41 @@ List getPlayingPlayers() { return newList; } + MagmaApi getMagma() { + return magmaApi; + } + + private void handleMagmaEvent(MagmaEvent magmaEvent) { + if (magmaEvent instanceof WebSocketClosed) { + WebSocketClosed event = (WebSocketClosed) magmaEvent; + JSONObject out = new JSONObject(); + out.put("op", "event"); + out.put("type", "WebSocketClosedEvent"); + out.put("guildId", event.getMember().getGuildId()); + out.put("reason", event.getReason()); + out.put("code", event.getCloseCode()); + out.put("byRemote", event.isByRemote()); + + Ws.send(socket, out); + } + } + void shutdown() { - log.info("Shutting down " + cores.size() + " cores and " + getPlayingPlayers().size() + " playing players."); + log.info("Shutting down " + getPlayingPlayers().size() + " playing players."); statsExecutor.shutdown(); audioPlayerManager.shutdown(); playerUpdateService.shutdown(); - players.keySet().forEach(s -> { - Core core = cores.get(Util.getShardFromSnowflake(s, shardCount)); - if (core != null) { - AudioManager audioManager = core.getAudioManager(s); - if (audioManager != null) { - audioManager.closeAudioConnection(); - } - } + players.keySet().forEach(guildId -> { + Member member = MagmaMember.builder() + .userId(userId) + .guildId(guildId) + .build(); + magmaApi.removeSendHandler(member); + magmaApi.closeConnection(member); }); players.values().forEach(Player::stop); - } - - private IAudioSendFactory getAudioSendFactory(int shardId) { - return sendFactories.computeIfAbsent(shardId % audioSendFactoryConfiguration.getAudioSendFactoryCount(), - integer -> { - Integer customBuffer = serverConfig.getBufferDurationMs(); - NativeAudioSendFactory nativeAudioSendFactory; - if (customBuffer != null) { - nativeAudioSendFactory = new NativeAudioSendFactory(customBuffer); - } else { - nativeAudioSendFactory = new NativeAudioSendFactory(); - } - - return AsyncPacketProviderFactory.adapt(nativeAudioSendFactory); - }); + magmaApi.shutdown(); } public AudioPlayerManager getAudioPlayerManager() { diff --git a/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.java b/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.java index 8cd7578b3..460657232 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.java +++ b/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.java @@ -22,142 +22,127 @@ package lavalink.server.io; +import com.github.shredder121.asyncaudio.jda.AsyncPacketProviderFactory; +import com.sedmelluq.discord.lavaplayer.jdaudp.NativeAudioSendFactory; import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; import com.sedmelluq.discord.lavaplayer.track.TrackMarker; import lavalink.server.config.AudioSendFactoryConfiguration; import lavalink.server.config.ServerConfig; -import lavalink.server.config.WebsocketConfig; import lavalink.server.player.Player; import lavalink.server.player.TrackEndMarkerHandler; import lavalink.server.util.Util; -import net.dv8tion.jda.Core; -import net.dv8tion.jda.manager.AudioManager; -import org.java_websocket.WebSocket; -import org.java_websocket.drafts.Draft; -import org.java_websocket.exceptions.InvalidDataException; -import org.java_websocket.handshake.ClientHandshake; -import org.java_websocket.handshake.ServerHandshakeBuilder; -import org.java_websocket.server.WebSocketServer; +import lavalink.server.util.Ws; +import net.dv8tion.jda.core.audio.factory.IAudioSendFactory; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; +import space.npstr.magma.*; -import javax.annotation.PostConstruct; import java.io.IOException; -import java.net.InetSocketAddress; import java.util.Collection; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; -import static lavalink.server.io.WSCodes.AUTHORIZATION_REJECTED; -import static lavalink.server.io.WSCodes.INTERNAL_ERROR; - -@Component -public class SocketServer extends WebSocketServer { +@Service +public class SocketServer extends TextWebSocketHandler { private static final Logger log = LoggerFactory.getLogger(SocketServer.class); - private final Map contextMap = new HashMap<>(); + // userId <-> shardCount + private final Map shardCounts = new ConcurrentHashMap<>(); + private final Map contextMap = new HashMap<>(); private final ServerConfig serverConfig; private final Supplier audioPlayerManagerSupplier; private final AudioSendFactoryConfiguration audioSendFactoryConfiguration; + private final ConcurrentHashMap sendFactories = new ConcurrentHashMap<>(); - public SocketServer(WebsocketConfig websocketConfig, ServerConfig serverConfig, Supplier audioPlayerManagerSupplier, + public SocketServer(ServerConfig serverConfig, Supplier audioPlayerManagerSupplier, AudioSendFactoryConfiguration audioSendFactoryConfiguration) { - super(new InetSocketAddress(websocketConfig.getHost(), websocketConfig.getPort())); - this.setReuseAddr(true); this.serverConfig = serverConfig; this.audioPlayerManagerSupplier = audioPlayerManagerSupplier; this.audioSendFactoryConfiguration = audioSendFactoryConfiguration; } + @SuppressWarnings("ConstantConditions") @Override - @PostConstruct - public void start() { - super.start(); - } + public void afterConnectionEstablished(WebSocketSession session) { + int shardCount = Integer.parseInt(session.getHandshakeHeaders().getFirst("Num-Shards")); + String userId = session.getHandshakeHeaders().getFirst("User-Id"); - @Override - public ServerHandshakeBuilder onWebsocketHandshakeReceivedAsServer(WebSocket conn, Draft draft, ClientHandshake request) throws InvalidDataException { - ServerHandshakeBuilder builder = super.onWebsocketHandshakeReceivedAsServer(conn, draft, request); - builder.put("Lavalink-Major-Version", "3"); - return builder; - } + shardCounts.put(userId, shardCount); - @Override - public void onOpen(WebSocket webSocket, ClientHandshake clientHandshake) { - try { - int shardCount = Integer.parseInt(clientHandshake.getFieldValue("Num-Shards")); - String userId = clientHandshake.getFieldValue("User-Id"); - - if (clientHandshake.getFieldValue("Authorization").equals(serverConfig.getPassword())) { - log.info("Connection opened from " + webSocket.getRemoteSocketAddress() + " with protocol " + webSocket.getDraft()); - contextMap.put(webSocket, new SocketContext(audioPlayerManagerSupplier, serverConfig, webSocket, - audioSendFactoryConfiguration, this, userId, shardCount)); - } else { - log.error("Authentication failed from " + webSocket.getRemoteSocketAddress() + " with protocol " + webSocket.getDraft()); - webSocket.close(AUTHORIZATION_REJECTED, "Authorization rejected"); - } - } catch (Exception e) { - log.error("Error when opening websocket", e); - webSocket.close(INTERNAL_ERROR, e.getMessage()); - } + contextMap.put(session.getId(), new SocketContext(audioPlayerManagerSupplier, session, this, userId)); + log.info("Connection successfully established from " + session.getRemoteAddress()); } @Override - public void onCloseInitiated(WebSocket webSocket, int code, String reason) { - close(webSocket, code, reason); - } - - @Override - public void onClosing(WebSocket webSocket, int code, String reason, boolean remote) { - close(webSocket, code, reason); - } - - @Override - public void onClose(WebSocket webSocket, int code, String reason, boolean remote) { - close(webSocket, code, reason); - } - - // WebSocketServer has a very questionable attitude towards communicating close events, so we override ALL the closing methods - private void close(WebSocket webSocket, int code, String reason) { - SocketContext context = contextMap.remove(webSocket); + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + SocketContext context = contextMap.remove(session.getId()); if (context != null) { - log.info("Connection closed from {} with protocol {} with reason {} with code {}", - webSocket.getRemoteSocketAddress().toString(), webSocket.getDraft(), reason, code); + log.info("Connection closed from {} -- {}", session.getRemoteAddress(), status); context.shutdown(); } } @Override - public void onMessage(WebSocket webSocket, String s) { - JSONObject json = new JSONObject(s); + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + try { + handleTextMessageSafe(session, message); + } catch (Exception e) { + log.error("Exception while handling websocket message", e); + } + } + + private void handleTextMessageSafe(WebSocketSession session, TextMessage message) { + JSONObject json = new JSONObject(message.getPayload()); - log.info(s); + log.info(message.getPayload()); - if (webSocket.isClosing()) { - log.error("Ignoring closing websocket: " + webSocket.getRemoteSocketAddress().toString()); + if (!session.isOpen()) { + log.error("Ignoring closing websocket: " + session.getRemoteAddress()); return; } switch (json.getString("op")) { /* JDAA ops */ case "voiceUpdate": - Core core = contextMap.get(webSocket).getCore(getShardId(webSocket, json)); - core.provideVoiceServerUpdate( - json.getString("sessionId"), - json.getJSONObject("event") - ); - core.getAudioManager(json.getJSONObject("event").getString("guild_id")).setAutoReconnect(false); + String sessionId = json.getString("sessionId"); + String guildId = json.getString("guildId"); + + JSONObject event = json.getJSONObject("event"); + String endpoint = event.optString("endpoint"); + String token = event.getString("token"); + + //discord sometimes send a partial server update missing the endpoint, which can be ignored. + if (endpoint == null || endpoint.isEmpty()) { + return; + } + + SocketContext sktContext = contextMap.get(session.getId()); + Member member = MagmaMember.builder() + .userId(sktContext.getUserId()) + .guildId(guildId) + .build(); + ServerUpdate serverUpdate = MagmaServerUpdate.builder() + .sessionId(sessionId) + .endpoint(endpoint) + .token(token) + .build(); + sktContext.getMagma().provideVoiceServerUpdate(member, serverUpdate); break; /* Player ops */ case "play": try { - SocketContext ctx = contextMap.get(webSocket); + SocketContext ctx = contextMap.get(session.getId()); Player player = ctx.getPlayer(json.getString("guildId")); AudioTrack track = Util.toAudioTrack(ctx.getAudioPlayerManager(), json.getString("track")); if (json.has("startTime")) { @@ -174,41 +159,47 @@ public void onMessage(WebSocket webSocket, String s) { player.play(track); - SocketContext context = contextMap.get(webSocket); + SocketContext context = contextMap.get(session.getId()); + + Member m = MagmaMember.builder() + .userId(context.getUserId()) + .guildId(json.getString("guildId")) + .build(); + context.getMagma().setSendHandler(m, context.getPlayer(json.getString("guildId"))); - context.getCore(getShardId(webSocket, json)).getAudioManager(json.getString("guildId")) - .setSendingHandler(context.getPlayer(json.getString("guildId"))); - sendPlayerUpdate(webSocket, player); + sendPlayerUpdate(session, player); } catch (IOException e) { throw new RuntimeException(e); } break; case "stop": - Player player = contextMap.get(webSocket).getPlayer(json.getString("guildId")); + Player player = contextMap.get(session.getId()).getPlayer(json.getString("guildId")); player.stop(); break; case "pause": - Player player2 = contextMap.get(webSocket).getPlayer(json.getString("guildId")); + Player player2 = contextMap.get(session.getId()).getPlayer(json.getString("guildId")); player2.setPause(json.getBoolean("pause")); - sendPlayerUpdate(webSocket, player2); + sendPlayerUpdate(session, player2); break; case "seek": - Player player3 = contextMap.get(webSocket).getPlayer(json.getString("guildId")); + Player player3 = contextMap.get(session.getId()).getPlayer(json.getString("guildId")); player3.seekTo(json.getLong("position")); - sendPlayerUpdate(webSocket, player3); + sendPlayerUpdate(session, player3); break; case "volume": - Player player4 = contextMap.get(webSocket).getPlayer(json.getString("guildId")); + Player player4 = contextMap.get(session.getId()).getPlayer(json.getString("guildId")); player4.setVolume(json.getInt("volume")); break; case "destroy": - Player player5 = contextMap.get(webSocket).getPlayers().remove(json.getString("guildId")); + SocketContext socketContext = contextMap.get(session.getId()); + Player player5 = socketContext.getPlayers().remove(json.getString("guildId")); if (player5 != null) player5.stop(); - AudioManager audioManager = contextMap.get(webSocket) - .getCore(getShardId(webSocket, json)) - .getAudioManager(json.getString("guildId")); - audioManager.setSendingHandler(null); - audioManager.closeAudioConnection(); + Member mem = MagmaMember.builder() + .userId(socketContext.getUserId()) + .guildId(json.getString("guildId")) + .build(); + socketContext.getMagma().removeSendHandler(mem); + socketContext.getMagma().closeConnection(mem); break; default: log.warn("Unexpected operation: " + json.getString("op")); @@ -216,32 +207,34 @@ public void onMessage(WebSocket webSocket, String s) { } } - @Override - public void onError(WebSocket webSocket, Exception e) { - log.error("Caught exception in websocket", e); - } - - @Override - public void onStart() { - log.info("Started WS server with port " + getPort()); - } - - public static void sendPlayerUpdate(WebSocket webSocket, Player player) { + public static void sendPlayerUpdate(WebSocketSession session, Player player) { JSONObject json = new JSONObject(); json.put("op", "playerUpdate"); json.put("guildId", player.getGuildId()); json.put("state", player.getState()); - webSocket.send(json.toString()); - } - - //Shorthand method - private int getShardId(WebSocket webSocket, JSONObject json) { - return Util.getShardFromSnowflake(json.getString("guildId"), contextMap.get(webSocket).getShardCount()); + Ws.send(session, json); } Collection getContexts() { return contextMap.values(); } + IAudioSendFactory getAudioSendFactory(Member member) { + int shardCount = shardCounts.getOrDefault(member.getUserId(), 1); + int shardId = Util.getShardFromSnowflake(member.getGuildId(), shardCount); + + return sendFactories.computeIfAbsent(shardId % audioSendFactoryConfiguration.getAudioSendFactoryCount(), + integer -> { + Integer customBuffer = serverConfig.getBufferDurationMs(); + NativeAudioSendFactory nativeAudioSendFactory; + if (customBuffer != null) { + nativeAudioSendFactory = new NativeAudioSendFactory(customBuffer); + } else { + nativeAudioSendFactory = new NativeAudioSendFactory(); + } + + return AsyncPacketProviderFactory.adapt(nativeAudioSendFactory); + }); + } } diff --git a/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java b/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java index 3f97f3cec..d29f489f4 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java +++ b/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java @@ -25,6 +25,7 @@ import lavalink.server.Launcher; import lavalink.server.player.AudioLossCounter; import lavalink.server.player.Player; +import lavalink.server.util.Ws; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,6 +34,8 @@ import oshi.software.os.OSProcess; import oshi.software.os.OperatingSystem; +import java.io.IOException; + public class StatsTask implements Runnable { private static final Logger log = LoggerFactory.getLogger(StatsTask.class); @@ -56,7 +59,7 @@ public void run() { } } - public void sendStats() { + private void sendStats() throws IOException { JSONObject out = new JSONObject(); final int[] playersTotal = {0}; @@ -116,14 +119,14 @@ public void sendStats() { out.put("frameStats", frames); } - context.getSocket().send(out.toString()); + Ws.send(context.getSession(), out); } private double uptime = 0; private double cpuTime = 0; private double getProcessRecentCpuUsage() { - double output = 0d; + double output; HardwareAbstractionLayer hal = si.getHardware(); OperatingSystem os = si.getOperatingSystem(); OSProcess p = os.getProcess(os.getProcessId()); diff --git a/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java b/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java index 336bf59c0..6ac253202 100644 --- a/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java +++ b/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java @@ -30,6 +30,7 @@ import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; import lavalink.server.io.SocketServer; import lavalink.server.util.Util; +import lavalink.server.util.Ws; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -61,7 +62,7 @@ public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason out.put("reason", endReason.toString()); - linkPlayer.getSocket().getSocket().send(out.toString()); + Ws.sendIfOpen(linkPlayer.getSocket().getSession(), out); } // These exceptions are already logged by Lavaplayer @@ -79,7 +80,7 @@ public void onTrackException(AudioPlayer player, AudioTrack track, FriendlyExcep out.put("error", exception.getMessage()); - linkPlayer.getSocket().getSocket().send(out.toString()); + Ws.sendIfOpen(linkPlayer.getSocket().getSession(), out); } @Override @@ -98,8 +99,8 @@ public void onTrackStuck(AudioPlayer player, AudioTrack track, long thresholdMs) out.put("thresholdMs", thresholdMs); - linkPlayer.getSocket().getSocket().send(out.toString()); - SocketServer.sendPlayerUpdate(linkPlayer.getSocket().getSocket(), linkPlayer); + Ws.sendIfOpen(linkPlayer.getSocket().getSession(), out); + SocketServer.sendPlayerUpdate(linkPlayer.getSocket().getSession(), linkPlayer); } } diff --git a/LavalinkServer/src/main/java/lavalink/server/player/Player.java b/LavalinkServer/src/main/java/lavalink/server/player/Player.java index 67af5313c..3c7201745 100644 --- a/LavalinkServer/src/main/java/lavalink/server/player/Player.java +++ b/LavalinkServer/src/main/java/lavalink/server/player/Player.java @@ -30,7 +30,7 @@ import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame; import lavalink.server.io.SocketContext; import lavalink.server.io.SocketServer; -import net.dv8tion.jda.audio.AudioSendHandler; +import net.dv8tion.jda.core.audio.AudioSendHandler; import org.json.JSONObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -75,7 +75,11 @@ public String getGuildId() { } public void seekTo(long position) { - player.getPlayingTrack().setPosition(position); + AudioTrack track = player.getPlayingTrack(); + + if (track == null) throw new RuntimeException("Can't seek when not playing anything"); + + track.setPosition(position); } public void setVolume(int volume) { @@ -136,7 +140,7 @@ public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason public void onTrackStart(AudioPlayer player, AudioTrack track) { if (myFuture == null || myFuture.isCancelled()) { myFuture = socketContext.playerUpdateService.scheduleAtFixedRate(() -> { - SocketServer.sendPlayerUpdate(socketContext.getSocket(), this); + SocketServer.sendPlayerUpdate(socketContext.getSession(), this); }, 0, 5, TimeUnit.SECONDS); } } diff --git a/LavalinkServer/src/main/java/lavalink/server/util/DebugConnectionListener.java b/LavalinkServer/src/main/java/lavalink/server/util/DebugConnectionListener.java deleted file mode 100644 index 2b5b10959..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/util/DebugConnectionListener.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2017 Frederik Ar. Mikkelsen & NoobLance - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package lavalink.server.util; - - -import net.dv8tion.jda.audio.hooks.ConnectionListener; -import net.dv8tion.jda.audio.hooks.ConnectionStatus; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * 65,42% copy pasta from the DebugConnectionListener in FredBoat - */ -public class DebugConnectionListener implements ConnectionListener { - - private static final Logger log = LoggerFactory.getLogger(DebugConnectionListener.class); - - private ConnectionStatus oldStatus = null; - private final long guildId; - - public DebugConnectionListener(long guildId) { - this.guildId = guildId; - } - - @Override - public void onPing(long l) { - - } - - @Override - public void onStatusChange(ConnectionStatus connectionStatus) { - log.debug("Status change for audio connection in guild {} : {} => {}", - guildId, oldStatus, connectionStatus); - - oldStatus = connectionStatus; - } - - @Override - public void onUserSpeaking(String s, boolean b) { - - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/util/SimpleLogToSLF4JAdapter.java b/LavalinkServer/src/main/java/lavalink/server/util/SimpleLogToSLF4JAdapter.java deleted file mode 100644 index 8ddc27f34..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/util/SimpleLogToSLF4JAdapter.java +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2017 Frederik Ar. Mikkelsen & NoobLance - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package lavalink.server.util; - -import net.dv8tion.jda.utils.SimpleLog; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * @author sedmelluq - */ -public class SimpleLogToSLF4JAdapter implements SimpleLog.LogListener { - private static final Logger log = LoggerFactory.getLogger("JDAA"); - - @Override - public void onLog(SimpleLog simpleLog, SimpleLog.Level logLevel, Object message) { - if (message == null) { - message = "null"; - } - - switch (logLevel) { - case TRACE: - if (log.isTraceEnabled()) { - log.trace(message.toString()); - } - break; - case DEBUG: - if (log.isDebugEnabled()) { - log.debug(message.toString()); - } - break; - case INFO: - log.info(message.toString()); - break; - case WARNING: - log.warn(message.toString()); - break; - case FATAL: - log.error(message.toString()); - break; - } - } - - @Override - public void onError(SimpleLog simpleLog, Throwable err) { - log.error("An exception occurred", err); - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/util/Ws.java b/LavalinkServer/src/main/java/lavalink/server/util/Ws.java new file mode 100644 index 000000000..bede83749 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/util/Ws.java @@ -0,0 +1,40 @@ +package lavalink.server.util; + +import io.undertow.websockets.core.WebSocketCallback; +import io.undertow.websockets.core.WebSocketChannel; +import io.undertow.websockets.core.WebSockets; +import io.undertow.websockets.jsr.UndertowSession; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.adapter.standard.StandardWebSocketSession; + +public class Ws { + + private static final Logger log = LoggerFactory.getLogger(Ws.class); + + /** + * Like #send(), but without logging an exception if the socket is closed. + */ + public static void sendIfOpen(WebSocketSession session, JSONObject json) { + if (session.isOpen()) send(session, json); + } + + public static void send(WebSocketSession session, JSONObject json) { + UndertowSession undertowSession = (UndertowSession) ((StandardWebSocketSession) session).getNativeSession(); + WebSockets.sendText(json.toString(), undertowSession.getWebSocketChannel(), + new WebSocketCallback<>() { + @Override + public void complete(WebSocketChannel channel, Void context) { + log.trace("Sent {}", json); + } + + @Override + public void onError(WebSocketChannel channel, Void context, Throwable throwable) { + log.error("Error", throwable); + } + }); + } + +} diff --git a/build.gradle b/build.gradle index bc53b316b..223af44e8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { - springBootVersion = '2.0.2.RELEASE' - gradleGitVersion = '1.5.1' + springBootVersion = '2.0.4.RELEASE' + gradleGitVersion = '1.5.2' sonarqubeVersion = '2.6.2' } repositories { @@ -21,7 +21,7 @@ allprojects { mavenCentral() // main maven repo jcenter() // JDA and some other stuff mavenLocal() // useful for developing - maven { url "https://jitpack.io" } // build projects directly from github + maven { url "https://jitpack.io" } // build projects directly from github } group = 'lavalink' @@ -47,18 +47,19 @@ subprojects { //@formatter:off lavaplayerVersion = '1.3.7' - jdaAudioVersion = '4d7abb48aec49f0a996ba0d87df34fdc67f71275' - jdaNasVersion = '1.0.6.2-JDA-Audio' - jappVersion = '1.2' + magmaVersion = '0.6.1' + jdaNasVersion = '1.0.6' + jappVersion = '1.3' springBootVersion = "${springBootVersion}" - javaWebSocketVersion = '1.3.8' + springWebSocketVersion = '5.0.8.RELEASE' logbackVersion = '1.2.3' - sentryLogbackVersion = '1.7.0' - oshiVersion = '3.5.0' - jsonOrgVersion = '20180130' - spotbugsAnnotationsVersion = '3.1.3' - prometheusVersion = '0.4.0' + sentryLogbackVersion = '1.7.7' + oshiVersion = '3.8.1' + jsonOrgVersion = '20180813' + spotbugsAnnotationsVersion = '3.1.6' + prometheusVersion = '0.5.0' + commonsLangVersion = '3.8' //@formatter:on } @@ -69,7 +70,7 @@ ext { } task wrapper(type: Wrapper) { - gradleVersion = '4.7' + gradleVersion = '4.10.2' //noinspection UnnecessaryQualifiedReference distributionType = Wrapper.DistributionType.ALL } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 91ca28c8b..29953ea14 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e6a30918e..d76b502e2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.2-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists