diff --git a/CHANGELOG.md b/CHANGELOG.md index 022c14832..07d8c1f6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ Each release usually includes various fixes and improvements. The most noteworthy of these, as well as any features and breaking changes, are listed here. +## v3.1.1 +* Add equalizer support +* Update lavaplayer to 1.3.10 +* Fixed automatic versioning +* Added build config to upload binaries to GitHub releases from CI + +Contributors: +[@Devoxin](https://github.com/Devoxin), +[@Frederikam](https://github.com/Frederikam/), +[@calebj](https://github.com/calebj) + +## v3.1 +* Replaced JDAA with Magma +* Added an event for when the Discord voice WebSocket is closed +* Replaced Tomcat and Java_Websocket with Undertow. WS and REST is now handled by the same +server and port. Port is specified by `server.port`. + ## v3.0 * **Breaking:** The minimum required Java version to run the server is now Java 10. **Please note**: Java 10 will be obsolete diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 674c496a6..88b3bf2ef 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -110,6 +110,24 @@ Set player volume. Volume may range from 0 to 1000. 100 is default. } ``` +Using the player equalizer +```json +{ + "op": "equalizer", + "guildId": "...", + "bands": [ + { + "band": 0, + "gain": 0.2 + } + ] +} +``` +There are 16 bands (0-15) that can be changed. +`gain` is the multiplier for the given band. The default value is 0. Valid values range from -0.25 to 1.0, +where -0.25 means the given band is completely muted, and 0.25 means it is doubled. Modifying the gain could +also change the volume of the output. + Tell the server to potentially disconnect from the voice server and potentially remove the player with all its data. This is useful if you want to move to a new node for a voice connection. Calling this op does not affect voice state, and you can send the same VOICE_SERVER_UPDATE to a new node. diff --git a/LavalinkServer/build.gradle b/LavalinkServer/build.gradle index a663ed22f..d0a7e639c 100644 --- a/LavalinkServer/build.gradle +++ b/LavalinkServer/build.gradle @@ -4,6 +4,8 @@ apply plugin: 'application' apply plugin: 'org.springframework.boot' apply plugin: 'com.gorylenko.gradle-git-properties' apply plugin: 'org.ajoberstar.grgit' +apply plugin: 'kotlin' +apply plugin: 'kotlin-spring' description = 'Play audio to discord voice channels' mainClassName = "lavalink.server.Launcher" @@ -39,6 +41,7 @@ dependencies { compile group: 'space.npstr', name: 'Magma', version: magmaVersion compile group: 'com.sedmelluq', name: 'lavaplayer', version: lavaplayerVersion compile group: 'com.sedmelluq', name: 'jda-nas', version: jdaNasVersion + compile group: 'org.jetbrains.kotlin', name: 'kotlin-reflect', version: kotlinVersion compile group: 'com.github.shredder121', name: 'jda-async-packetprovider', version: jappVersion //required by japp @@ -79,6 +82,17 @@ build { } } +compileKotlin { + kotlinOptions { + jvmTarget = "1.8" + } +} +compileTestKotlin { + kotlinOptions { + jvmTarget = "1.8" + } +} + @SuppressWarnings("GrMethodMayBeStatic") String versionFromTag() { diff --git a/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.java b/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.java deleted file mode 100644 index bfa2a3b43..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.java +++ /dev/null @@ -1,145 +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 com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; -import lavalink.server.player.Player; -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.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; - -public class SocketContext { - - private static final Logger log = LoggerFactory.getLogger(SocketContext.class); - - private final AudioPlayerManager audioPlayerManager; - private final WebSocketSession socket; - private String userId; - private final MagmaApi magmaApi; - //guildId <-> Player - private final Map players = new ConcurrentHashMap<>(); - private ScheduledExecutorService statsExecutor; - public final ScheduledExecutorService playerUpdateService; - - SocketContext(Supplier audioPlayerManagerSupplier, WebSocketSession socket, - SocketServer socketServer, String userId) { - this.audioPlayerManager = audioPlayerManagerSupplier.get(); - this.socket = socket; - this.userId = userId; - this.magmaApi = MagmaApi.of(socketServer::getAudioSendFactory); - magmaApi.getEventStream().subscribe(this::handleMagmaEvent); - - statsExecutor = Executors.newSingleThreadScheduledExecutor(); - statsExecutor.scheduleAtFixedRate(new StatsTask(this, socketServer), 0, 1, TimeUnit.MINUTES); - - playerUpdateService = Executors.newScheduledThreadPool(2, r -> { - Thread thread = new Thread(r); - thread.setName("player-update"); - thread.setDaemon(true); - return thread; - }); - } - - public String getUserId() { - return userId; - } - - Player getPlayer(String guildId) { - return players.computeIfAbsent(guildId, - __ -> new Player(this, guildId, audioPlayerManager) - ); - } - - public WebSocketSession getSession() { - return socket; - } - - Map getPlayers() { - return players; - } - - List getPlayingPlayers() { - List newList = new LinkedList<>(); - players.values().forEach(player -> { - if (player.isPlaying()) newList.add(player); - }); - 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 " + getPlayingPlayers().size() + " playing players."); - statsExecutor.shutdown(); - audioPlayerManager.shutdown(); - playerUpdateService.shutdown(); - players.keySet().forEach(guildId -> { - Member member = MagmaMember.builder() - .userId(userId) - .guildId(guildId) - .build(); - magmaApi.removeSendHandler(member); - magmaApi.closeConnection(member); - }); - - players.values().forEach(Player::stop); - magmaApi.shutdown(); - } - - public AudioPlayerManager getAudioPlayerManager() { - return audioPlayerManager; - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.kt b/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.kt new file mode 100644 index 000000000..910de9033 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.kt @@ -0,0 +1,120 @@ +/* + * 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 com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager +import lavalink.server.player.Player +import lavalink.server.util.Ws +import org.json.JSONObject +import org.slf4j.LoggerFactory +import org.springframework.web.socket.WebSocketSession +import space.npstr.magma.MagmaApi +import space.npstr.magma.MagmaMember +import space.npstr.magma.events.api.MagmaEvent +import space.npstr.magma.events.api.WebSocketClosed +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import java.util.function.Consumer +import java.util.function.Supplier + +class SocketContext internal constructor(audioPlayerManagerSupplier: Supplier, val session: WebSocketSession, + socketServer: SocketServer, val userId: String) { + + val audioPlayerManager: AudioPlayerManager = audioPlayerManagerSupplier.get() + internal val magma: MagmaApi = MagmaApi.of { socketServer.getAudioSendFactory(it) } + //guildId <-> Player + val players = ConcurrentHashMap() + private val statsExecutor: ScheduledExecutorService + val playerUpdateService: ScheduledExecutorService + + val playingPlayers: List + get() { + val newList = LinkedList() + players.values.forEach { player -> if (player.isPlaying) newList.add(player) } + return newList + } + + + init { + magma.eventStream.subscribe { this.handleMagmaEvent(it) } + + statsExecutor = Executors.newSingleThreadScheduledExecutor() + statsExecutor.scheduleAtFixedRate(StatsTask(this, socketServer), 0, 1, TimeUnit.MINUTES) + + playerUpdateService = Executors.newScheduledThreadPool(2) { r -> + val thread = Thread(r) + thread.name = "player-update" + thread.isDaemon = true + thread + } + } + + internal fun getPlayer(guildId: String): Player { + return players.computeIfAbsent(guildId + ) { _ -> Player(this, guildId, audioPlayerManager) } + } + + internal fun getPlayers(): Map { + return players + } + + private fun handleMagmaEvent(magmaEvent: MagmaEvent) { + if (magmaEvent is WebSocketClosed) { + val out = JSONObject() + out.put("op", "event") + out.put("type", "WebSocketClosedEvent") + out.put("guildId", magmaEvent.member.guildId) + out.put("reason", magmaEvent.reason) + out.put("code", magmaEvent.closeCode) + out.put("byRemote", magmaEvent.isByRemote) + + Ws.send(session, out) + } + } + + internal fun shutdown() { + log.info("Shutting down " + playingPlayers.size + " playing players.") + statsExecutor.shutdown() + audioPlayerManager.shutdown() + playerUpdateService.shutdown() + players.keys.forEach { guildId -> + val member = MagmaMember.builder() + .userId(userId) + .guildId(guildId) + .build() + magma.removeSendHandler(member) + magma.closeConnection(member) + } + + players.values.forEach(Consumer { it.stop() }) + magma.shutdown() + } + + companion object { + + private val log = LoggerFactory.getLogger(SocketContext::class.java) + } +} diff --git a/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.java b/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.java deleted file mode 100644 index 460657232..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.java +++ /dev/null @@ -1,240 +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 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.player.Player; -import lavalink.server.player.TrackEndMarkerHandler; -import lavalink.server.util.Util; -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.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 java.io.IOException; -import java.util.Collection; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Supplier; - -@Service -public class SocketServer extends TextWebSocketHandler { - - private static final Logger log = LoggerFactory.getLogger(SocketServer.class); - - // 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(ServerConfig serverConfig, Supplier audioPlayerManagerSupplier, - AudioSendFactoryConfiguration audioSendFactoryConfiguration) { - this.serverConfig = serverConfig; - this.audioPlayerManagerSupplier = audioPlayerManagerSupplier; - this.audioSendFactoryConfiguration = audioSendFactoryConfiguration; - } - - @SuppressWarnings("ConstantConditions") - @Override - public void afterConnectionEstablished(WebSocketSession session) { - int shardCount = Integer.parseInt(session.getHandshakeHeaders().getFirst("Num-Shards")); - String userId = session.getHandshakeHeaders().getFirst("User-Id"); - - shardCounts.put(userId, shardCount); - - contextMap.put(session.getId(), new SocketContext(audioPlayerManagerSupplier, session, this, userId)); - log.info("Connection successfully established from " + session.getRemoteAddress()); - } - - @Override - public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { - SocketContext context = contextMap.remove(session.getId()); - if (context != null) { - log.info("Connection closed from {} -- {}", session.getRemoteAddress(), status); - context.shutdown(); - } - } - - @Override - 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(message.getPayload()); - - if (!session.isOpen()) { - log.error("Ignoring closing websocket: " + session.getRemoteAddress()); - return; - } - - switch (json.getString("op")) { - /* JDAA ops */ - case "voiceUpdate": - 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(session.getId()); - Player player = ctx.getPlayer(json.getString("guildId")); - AudioTrack track = Util.toAudioTrack(ctx.getAudioPlayerManager(), json.getString("track")); - if (json.has("startTime")) { - track.setPosition(json.getLong("startTime")); - } - if (json.has("endTime")) { - track.setMarker(new TrackMarker(json.getLong("endTime"), new TrackEndMarkerHandler(player))); - } - - player.setPause(json.optBoolean("pause", false)); - if (json.has("volume")) { - player.setVolume(json.getInt("volume")); - } - - player.play(track); - - 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"))); - - sendPlayerUpdate(session, player); - } catch (IOException e) { - throw new RuntimeException(e); - } - break; - case "stop": - Player player = contextMap.get(session.getId()).getPlayer(json.getString("guildId")); - player.stop(); - break; - case "pause": - Player player2 = contextMap.get(session.getId()).getPlayer(json.getString("guildId")); - player2.setPause(json.getBoolean("pause")); - sendPlayerUpdate(session, player2); - break; - case "seek": - Player player3 = contextMap.get(session.getId()).getPlayer(json.getString("guildId")); - player3.seekTo(json.getLong("position")); - sendPlayerUpdate(session, player3); - break; - case "volume": - Player player4 = contextMap.get(session.getId()).getPlayer(json.getString("guildId")); - player4.setVolume(json.getInt("volume")); - break; - case "destroy": - SocketContext socketContext = contextMap.get(session.getId()); - Player player5 = socketContext.getPlayers().remove(json.getString("guildId")); - if (player5 != null) player5.stop(); - 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")); - break; - } - } - - 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()); - - 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/SocketServer.kt b/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt new file mode 100644 index 000000000..82ed10d5d --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt @@ -0,0 +1,145 @@ +/* + * 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 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.TrackMarker +import lavalink.server.config.AudioSendFactoryConfiguration +import lavalink.server.config.ServerConfig +import lavalink.server.player.Player +import lavalink.server.player.TrackEndMarkerHandler +import lavalink.server.util.Util +import lavalink.server.util.Ws +import net.dv8tion.jda.core.audio.factory.IAudioSendFactory +import org.json.JSONObject +import org.slf4j.LoggerFactory +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 java.io.IOException +import java.util.HashMap +import java.util.concurrent.ConcurrentHashMap +import java.util.function.Supplier + +@Service +class SocketServer(private val serverConfig: ServerConfig, private val audioPlayerManagerSupplier: Supplier, + private val audioSendFactoryConfiguration: AudioSendFactoryConfiguration) : TextWebSocketHandler() { + + // userId <-> shardCount + private val shardCounts = ConcurrentHashMap() + private val contextMap = HashMap() + private val sendFactories = ConcurrentHashMap() + private val handlers = WebSocketHandlers(contextMap) + + + val contexts: Collection + get() = contextMap.values + + override fun afterConnectionEstablished(session: WebSocketSession) { + val shardCount = Integer.parseInt(session.handshakeHeaders.getFirst("Num-Shards")!!) + val userId = session.handshakeHeaders.getFirst("User-Id")!! + + shardCounts[userId] = shardCount + + contextMap[session.id] = SocketContext(audioPlayerManagerSupplier, session, this, userId) + log.info("Connection successfully established from " + session.remoteAddress!!) + } + + override fun afterConnectionClosed(session: WebSocketSession?, status: CloseStatus?) { + val context = contextMap.remove(session!!.id) + if (context != null) { + log.info("Connection closed from {} -- {}", session.remoteAddress, status) + context.shutdown() + } + } + + override fun handleTextMessage(session: WebSocketSession?, message: TextMessage?) { + try { + handleTextMessageSafe(session!!, message!!) + } catch (e: Exception) { + log.error("Exception while handling websocket message", e) + } + + } + + private fun handleTextMessageSafe(session: WebSocketSession, message: TextMessage) { + val json = JSONObject(message.payload) + + log.info(message.payload) + + if (!session.isOpen) { + log.error("Ignoring closing websocket: " + session.remoteAddress!!) + return + } + + when (json.getString("op")) { + "voiceUpdate" -> handlers.voiceUpdate(session, json) + "play" -> handlers.play(session, json) + "stop" -> handlers.stop(session, json) + "pause" -> handlers.pause(session, json) + "seek" -> handlers.seek(session, json) + "volume" -> handlers.volume(session, json) + "equalizer" -> handlers.equalizer(session, json) + "destroy" -> handlers.destroy(session, json) + else -> log.warn("Unexpected operation: " + json.getString("op")) + } + } + + fun getAudioSendFactory(member: Member): IAudioSendFactory { + val shardCount = shardCounts.getOrDefault(member.userId, 1) + val shardId = Util.getShardFromSnowflake(member.guildId, shardCount) + + return sendFactories.computeIfAbsent(shardId % audioSendFactoryConfiguration.audioSendFactoryCount + ) { _ -> + val customBuffer = serverConfig.bufferDurationMs + val nativeAudioSendFactory: NativeAudioSendFactory + nativeAudioSendFactory = if (customBuffer != null) { + NativeAudioSendFactory(customBuffer) + } else { + NativeAudioSendFactory() + } + + AsyncPacketProviderFactory.adapt(nativeAudioSendFactory) + } + } + + companion object { + + private val log = LoggerFactory.getLogger(SocketServer::class.java) + + fun sendPlayerUpdate(session: WebSocketSession, player: Player) { + val json = JSONObject() + json.put("op", "playerUpdate") + json.put("guildId", player.guildId) + json.put("state", player.state) + + Ws.send(session, json) + } + } +} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java b/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java index d29f489f4..a9237893f 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java +++ b/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java @@ -59,7 +59,7 @@ public void run() { } } - private void sendStats() throws IOException { + private void sendStats() { JSONObject out = new JSONObject(); final int[] playersTotal = {0}; diff --git a/LavalinkServer/src/main/java/lavalink/server/io/WebSocketHandlers.kt b/LavalinkServer/src/main/java/lavalink/server/io/WebSocketHandlers.kt new file mode 100644 index 000000000..7356d3e9e --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/io/WebSocketHandlers.kt @@ -0,0 +1,113 @@ +package lavalink.server.io + +import com.sedmelluq.discord.lavaplayer.track.TrackMarker +import lavalink.server.player.TrackEndMarkerHandler +import lavalink.server.util.Util +import org.json.JSONObject +import org.springframework.web.socket.WebSocketSession +import space.npstr.magma.MagmaMember +import space.npstr.magma.MagmaServerUpdate +import java.util.HashMap + +class WebSocketHandlers(private val contextMap: Map) { + + fun voiceUpdate(session: WebSocketSession, json: JSONObject) { + val sessionId = json.getString("sessionId") + val guildId = json.getString("guildId") + + val event = json.getJSONObject("event") + val endpoint = event.optString("endpoint") + val token = event.getString("token") + + //discord sometimes send a partial server update missing the endpoint, which can be ignored. + if (endpoint == null || endpoint.isEmpty()) { + return + } + + val sktContext = contextMap[session.id]!! + val member = MagmaMember.builder() + .userId(sktContext.userId) + .guildId(guildId) + .build() + val serverUpdate = MagmaServerUpdate.builder() + .sessionId(sessionId) + .endpoint(endpoint) + .token(token) + .build() + sktContext.magma.provideVoiceServerUpdate(member, serverUpdate) + } + + fun play(session: WebSocketSession, json: JSONObject) { + val ctx = contextMap[session.id]!! + val player = ctx.getPlayer(json.getString("guildId")) + val track = Util.toAudioTrack(ctx.audioPlayerManager, json.getString("track")) + if (json.has("startTime")) { + track.position = json.getLong("startTime") + } + if (json.has("endTime")) { + track.setMarker(TrackMarker(json.getLong("endTime"), TrackEndMarkerHandler(player))) + } + + player.setPause(json.optBoolean("pause", false)) + if (json.has("volume")) { + player.setVolume(json.getInt("volume")) + } + + player.play(track) + + val context = contextMap[session.id]!! + + val m = MagmaMember.builder() + .userId(context.userId) + .guildId(json.getString("guildId")) + .build() + context.magma.setSendHandler(m, context.getPlayer(json.getString("guildId"))) + + SocketServer.sendPlayerUpdate(session, player) + } + + fun stop(session: WebSocketSession, json: JSONObject) { + val player = contextMap[session.id]!!.getPlayer(json.getString("guildId")) + player.stop() + } + + fun pause(session: WebSocketSession, json: JSONObject) { + val player = contextMap[session.id]!!.getPlayer(json.getString("guildId")) + player.setPause(json.getBoolean("pause")) + SocketServer.sendPlayerUpdate(session, player) + } + + fun seek(session: WebSocketSession, json: JSONObject) { + val player = contextMap[session.id]!!.getPlayer(json.getString("guildId")) + player.seekTo(json.getLong("position")) + SocketServer.sendPlayerUpdate(session, player) + } + + fun volume(session: WebSocketSession, json: JSONObject) { + val player = contextMap[session.id]!!.getPlayer(json.getString("guildId")) + player.setVolume(json.getInt("volume")) + } + + fun equalizer(session: WebSocketSession, json: JSONObject) { + val player = contextMap[session.id]!!.getPlayer(json.getString("guildId")) + val bands = json.getJSONArray("bands") + + for (i in 0 until bands.length()) { + val band = bands.getJSONObject(i) + player.setBandGain(band.getInt("band"), band.getFloat("gain")) + } + } + + fun destroy(session: WebSocketSession, json: JSONObject) { + val socketContext = contextMap[session.id]!! + val player = socketContext.players.remove(json.getString("guildId")) + player?.stop() + val mem = MagmaMember.builder() + .userId(socketContext.userId) + .guildId(json.getString("guildId")) + .build() + socketContext.magma.removeSendHandler(mem) + socketContext.magma.closeConnection(mem) + } + +} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java b/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java index 6ac253202..2e6e5c5f0 100644 --- a/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java +++ b/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java @@ -100,7 +100,7 @@ public void onTrackStuck(AudioPlayer player, AudioTrack track, long thresholdMs) out.put("thresholdMs", thresholdMs); Ws.sendIfOpen(linkPlayer.getSocket().getSession(), out); - SocketServer.sendPlayerUpdate(linkPlayer.getSocket().getSession(), linkPlayer); + SocketServer.Companion.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 3c7201745..ac8ad1822 100644 --- a/LavalinkServer/src/main/java/lavalink/server/player/Player.java +++ b/LavalinkServer/src/main/java/lavalink/server/player/Player.java @@ -22,6 +22,8 @@ package lavalink.server.player; +import com.sedmelluq.discord.lavaplayer.filter.equalizer.Equalizer; +import com.sedmelluq.discord.lavaplayer.filter.equalizer.EqualizerFactory; import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter; @@ -48,6 +50,8 @@ public class Player extends AudioEventAdapter implements AudioSendHandler { private AudioLossCounter audioLossCounter = new AudioLossCounter(); private AudioFrame lastFrame = null; private ScheduledFuture myFuture = null; + private EqualizerFactory equalizerFactory = new EqualizerFactory(); + private boolean isEqualizerApplied = false; public Player(SocketContext socketContext, String guildId, AudioPlayerManager audioPlayerManager) { this.socketContext = socketContext; @@ -86,6 +90,32 @@ public void setVolume(int volume) { player.setVolume(volume); } + public void setBandGain(int band, float gain) { + equalizerFactory.setGain(band, gain); + + if (gain == 0.0f) { + if (!isEqualizerApplied) { + return; + } + + boolean shouldDisable = true; + + for (int i = 0; i < Equalizer.BAND_COUNT; i++) { + if (equalizerFactory.getGain(i) != 0.0f) { + shouldDisable = false; + } + } + + if (shouldDisable) { + this.player.setFilterFactory(null); + this.isEqualizerApplied = false; + } + } else { + this.player.setFilterFactory(equalizerFactory); + this.isEqualizerApplied = true; + } + } + public JSONObject getState() { JSONObject json = new JSONObject(); @@ -139,8 +169,8 @@ public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason @Override public void onTrackStart(AudioPlayer player, AudioTrack track) { if (myFuture == null || myFuture.isCancelled()) { - myFuture = socketContext.playerUpdateService.scheduleAtFixedRate(() -> { - SocketServer.sendPlayerUpdate(socketContext.getSession(), this); + myFuture = socketContext.getPlayerUpdateService().scheduleAtFixedRate(() -> { + SocketServer.Companion.sendPlayerUpdate(socketContext.getSession(), this); }, 0, 5, TimeUnit.SECONDS); } } diff --git a/build.gradle b/build.gradle index c86424203..eb07d24e8 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ buildscript { springBootVersion = '2.0.4.RELEASE' gradleGitVersion = '1.5.2' sonarqubeVersion = '2.6.2' + kotlinVersion = '1.2.71' } repositories { maven { url "https://plugins.gradle.org/m2/" } @@ -12,6 +13,8 @@ buildscript { classpath "gradle.plugin.com.gorylenko.gradle-git-properties:gradle-git-properties:${gradleGitVersion}" classpath "org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}" classpath "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:${sonarqubeVersion}" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlinVersion" } }