diff --git a/.gitignore b/.gitignore index f91335989..a26490138 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ build/* /Testbot/out/ /plugin-api/build/ /plugin-api/out/ +/protocol/build/ +/protocol/out/ gradle.properties application.yml LavalinkServer/plugins diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 499a7c57b..ea46470ec 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -1,25 +1,73 @@ # Implementation guidelines + How to write your own client. The Java [Lavalink-Client](https://github.com/freyacodes/lavalink-client) will serve as an example implementation. The Java client has support for JDA, but can also be adapted to work with other JVM libraries. ## Requirements -* You must be able to send messages via a shard's mainWS connection. -* You must be able to intercept voice server updates from mainWS on your shard connection. + +* You must be able to send messages via a shard's gateway connection. +* You must be able to intercept voice server & voice state updates from the gateway on your shard connection. + +## Significant changes v3.6.0 -> v3.7.0 + +* Moved HTTP endpoints under the new `/v3` path with `/version` as the only exception. +* Deprecation of the old HTTP paths. +* WebSocket handshakes should be done with `/v3/websocket`. Handshakes on `/` are now deprecated. +* Deprecation of all client-to-server messages (play, stop, pause, seek, volume, filters, destroy, voiceUpdate & configureResuming). +* Addition of REST endpoints intended to replace client requests. +* Addition of new WebSocket dispatch [Ready OP](#ready-op) to get `sessionId` and `resume` status. +* Addition of new [Session](#update-session)/[Player](#get-player) REST API. +* Addition of `/v3/info`, replaces `/plugins`. +* Deprecation of `Track.track` in existing endpoints. Use `Track.encoded` instead. +* Deprecation of `TrackXEvent.track` in WebSocket dispatches. Use `TrackXEvent.encodedTrack` instead. + +
+v3.7.0 Migration Guide + +Following ops are deprecated as of `v3.7.0` and replaced with the following endpoints and json fields: + +* `play` -> [Update Player Endpoint](#update-player) `track` or `identifier` field +* `stop` -> [Update Player Endpoint](#update-player) `track` field with `null` +* `pause` -> [Update Player Endpoint](#update-player) `pause` field +* `seek` -> [Update Player Endpoint](#update-player) `position` field +* `volume` -> [Update Player Endpoint](#update-player) `volume` field +* `filters` -> [Update Player Endpoint](#update-player) `filters` field +* `destroy` -> [Destroy Player Endpoint](#destroy-player) +* `voiceUpdate` -> [Update Player Endpoint](#update-player) `voice` field +* `configureResuming` -> [Update Session Endpoint](#update-session) + +
+ +## Future breaking changes for v4 + +* HTTP endpoints not under a version path (`/v3`, `/v4`) will be removed in v4. +* No WebSocket messages will be accepted by `/v4/websocket`. +* `/v3` will still be available. + +## Future breaking changes for v5 + +* Removal of `/v3`. `/v4` will still be available. + +
+Older versions ## Significant changes v3.3 -> v3.4 + * Added filters -* The `error` string on the `TrackExceptionEvent` has been deprecated and replaced by -the `exception` object following the same structure as the `LOAD_FAILED` error on [`/loadtracks`](#track-loading-api) +* The `error` string on the `TrackExceptionEvent` has been deprecated and replaced by + the [exception object](#exception-object) following the same structure as the `LOAD_FAILED` error on [`/loadtracks`](#track-loading). * Added the `connected` boolean to player updates. * Added source name to REST api track objects * Clients are now requested to make their name known during handshake -## Significant changes v2.0 -> v3.0 +## Significant changes v2.0 -> v3.0 + * The response of `/loadtracks` has been completely changed (again since the initial v3.0 pre-release). * Lavalink v3.0 now reports its version as a handshake response header. -`Lavalink-Major-Version` has a value of `3` for v3.0 only. It's missing for any older version. + `Lavalink-Major-Version` has a value of `3` for v3.0 only. It's missing for any older version. + +## Significant changes v1.3 -> v2.0 -## Significant changes v1.3 -> v2.0 With the release of v2.0 many unnecessary ops were removed: * `connect` @@ -30,10 +78,10 @@ With the release of v2.0 many unnecessary ops were removed: * `isConnectedReq` * `sendWS` -With Lavalink 1.x the server had the responsibility of handling Discord VOICE_SERVER_UPDATEs as well as its own internal ratelimiting. -This remote handling makes things unnecessarily complicated and adds a lot og points where things could go wrong. -One problem we noticed is that since JDAA is unaware of ratelimits on the bot's gateway connection, it would keep adding -to the ratelimit queue to the gateway. With this update this is now the responsibility of the Lavalink client or the +With Lavalink 1.x the server had the responsibility of handling Discord `VOICE_SERVER_UPDATE`s as well as its own internal ratelimiting. +This remote handling makes things unnecessarily complicated and adds a lot og points where things could go wrong. +One problem we noticed is that since JDA is unaware of ratelimits on the bot's gateway connection, it would keep adding +to the ratelimit queue to the gateway. With this update this is now the responsibility of the Lavalink client or the Discord client. A voice connection is now initiated by forwarding a `voiceUpdate` (VOICE_SERVER_UPDATE) to the server. When you want to @@ -44,582 +92,885 @@ will be disconnected by Discord. Depending on your Discord library, it may be possible to take advantage of the library's OP 4 handling. For instance, the JDA client takes advantage of JDA's websocket write thread to send OP 4s for connects, disconnects and reconnects. +
+ ## Protocol + +### Reference + +Fields marked with `?` are optional and types marked with `?` are nullable. + ### Opening a connection + +You can establish a WebSocket connection against the path `/v3/websocket` + When opening a websocket connection, you must supply 3 required headers: + +| Header Name | Description | +|-----------------|-------------------------------------------------| +| `Authorization` | The password you set in your Lavalink config. | +| `User-Id` | The user id of the bot. | +| `Client-Name` | The name of the client in `NAME/VERSION` format | +| `Resume-Key`?* | The configured key to resume a session | + +**\*For more information on resuming see [Resuming](#resuming-lavalink-sessions)** + +
+Example Headers + ``` -Authorization: Password matching the server config -User-Id: The user id of the bot you are playing music with -Client-Name: The name of your client. Optionally in the format NAME/VERSION +Authorization: youshallnotpass +User-Id: 170939974227541168 +Client-Name: lavalink-client/2.0.0 ``` -### Outgoing messages +
-#### Provide a voice server update +### Websocket Messages -Provide an intercepted voice server update. This causes the server to connect to the voice channel. +Websocket messages all follow the following standard format: -```json +| Field | Type | Description | +|-------|----------------------|---------------------------------------| +| op | [OP Type](#op-types) | The op type | +| ... | ... | Extra fields depending on the op type | + +
+Example Payload + +```yaml { - "op": "voiceUpdate", - "guildId": "...", - "sessionId": "...", - "event": {...} + "op": "...", + ... } ``` -#### Play a track +
-`startTime` is an optional setting that determines the number of milliseconds to offset the track by. Defaults to 0. +#### OP Types -`endTime` is an optional setting that determines at the number of milliseconds at which point the track should stop playing. Helpful if you only want to play a snippet of a bigger track. By default the track plays until it's end as per the encoded data. +| OP Type | Description | +|-----------------------------------|--------------------------------------------------------------| +| [ready](#ready-op) | Emitted when you successfully connect to the Lavalink node | +| [playerUpdate](#player-update-op) | Emitted every x seconds with the latest player state | +| [stats](#stats-op) | Emitted when the node sends stats once per minute | +| [event](#event-op) | Emitted when a player or voice event is emitted | -`volume` is an optional setting which changes the volume if provided. +#### Ready OP -If `noReplace` is set to true, this operation will be ignored if a track is already playing or paused. This is an optional field. +Dispatched by Lavalink upon successful connection and authorization. Contains fields determining if resuming was successful, as well as the session ID. -If `pause` is set to true, the playback will be paused. This is an optional field. +| Field | Type | Description | +|-----------|--------|------------------------------------------------------------------------------------------------| +| resumed | bool | If a session was resumed | +| sessionId | string | The Lavalink session ID of this connection. Not to be confused with a Discord voice session id | + +
+Example Payload ```json { - "op": "play", - "guildId": "...", - "track": "...", - "startTime": "60000", - "endTime": "120000", - "volume": "100", - "noReplace": false, - "pause": false + "op": "ready", + "resumed": false, + "sessionId": "..." } ``` -#### Stop a player +
-```json -{ - "op": "stop", - "guildId": "..." -} -``` +--- -#### Pause the playback +#### Player Update OP -```json -{ - "op": "pause", - "guildId": "...", - "pause": true -} -``` +Dispatched every x (configurable in `application.yml`) seconds with the current state of the player. -#### Seek a track +| Field | Type | Description | +|---------|--------------------------------------|----------------------------| +| guildId | string | The guild id of the player | +| state | [Player State](#player-state) object | The player state | -The position is in milliseconds. +##### Player State + +| Field | Type | Description | +|-----------|------|----------------------------------------------------------------------------------------| +| time | int | Unix timestamp in milliseconds | +| position? | int | The position of the track in milliseconds | +| connected | bool | If Lavalink is connected to the voice gateway | +| ping | int | The ping of the node to the Discord voice server in milliseconds (-1 if not connected) | + +
+Example Payload ```json { - "op": "seek", - "guildId": "...", - "position": 60000 + "op": "playerUpdate", + "guildId": "...", + "state": { + "time": 1500467109, + "position": 60000, + "connected": true, + "ping": 50 + } } ``` -#### Set player volume +
-Volume may range from 0 to 1000. 100 is default. +--- + +#### Stats OP + +A collection of stats sent every minute. + +##### Stats Object + +| Field | Type | Description | +|----------------|-------------------------------------|--------------------------------------------------------------------------------------------------| +| players | int | The amount of players connected to the node | +| playingPlayers | int | The amount of players playing a track | +| uptime | int | The uptime of the node in milliseconds | +| memory | [Memory](#memory) object | The memory stats of the node | +| cpu | [CPU](#cpu) object | The cpu stats of the node | +| frameStats | ?[Frame Stats](#frame-stats) object | The frame stats of the node. `null` if the node has no players or when retrieved via `/v3/stats` | + +##### Memory + +| Field | Type | Description | +|------------|------|------------------------------------------| +| free | int | The amount of free memory in bytes | +| used | int | The amount of used memory in bytes | +| allocated | int | The amount of allocated memory in bytes | +| reservable | int | The amount of reservable memory in bytes | + +##### CPU + +| Field | Type | Description | +|--------------|-------|----------------------------------| +| cores | int | The amount of cores the node has | +| systemLoad | float | The system load of the node | +| lavalinkLoad | float | The load of Lavalink on the node | + +##### Frame Stats + +| Field | Type | Description | +|---------|------|----------------------------------------| +| sent | int | The amount of frames sent to Discord | +| nulled | int | The amount of frames that were nulled | +| deficit | int | The amount of frames that were deficit | + +
+Example Payload ```json { - "op": "volume", - "guildId": "...", - "volume": 125 + "op": "stats", + "players": 1, + "playingPlayers": 1, + "uptime": 123456789, + "memory": { + "free": 123456789, + "used": 123456789, + "allocated": 123456789, + "reservable": 123456789 + }, + "cpu": { + "cores": 4, + "systemLoad": 0.5, + "lavalinkLoad": 0.5 + }, + "frameStats": { + "sent": 123456789, + "nulled": 123456789, + "deficit": 123456789 + } } ``` -#### Using filters +
-The `filters` op sets the filters. All the filters are optional, and leaving them out of this message will disable them. +--- -Adding a filter can have adverse effects on performance. These filters force Lavaplayer to decode all audio to PCM, -even if the input was already in the Opus format that Discord uses. This means decoding and encoding audio that would -normally require very little processing. This is often the case with YouTube videos. +#### Event OP -JSON comments are for illustration purposes only, and will not be accepted by the server. +Server emitted an event. See the client implementation below. + +| Field | Type | Description | +|---------|---------------------------|-------------------------------------| +| type | [EventType](#event-types) | The type of event | +| guildId | string | The guild id | +| ... | ... | Extra fields depending on the event | -Note that filters may take a moment to apply. +
+Example Payload ```yaml { - "op": "filters", - "guildId": "...", - - // Float value where 1.0 is 100%. Values >1.0 may cause clipping - "volume": 1.0, // 0 ≤ x ≤ 5 - - // There are 15 bands (0-14) 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. - "equalizer": [ - { - "band": 0, // 0 ≤ x ≤ 14 - "gain": 0.2 // -0.25 ≤ x ≤ 1 - } - ], - - // Uses equalization to eliminate part of a band, usually targeting vocals. - "karaoke": { - "level": 1.0, - "monoLevel": 1.0, - "filterBand": 220.0, - "filterWidth": 100.0 - }, - - // Changes the speed, pitch, and rate. All default to 1. - "timescale": { - "speed": 1.0, // 0 ≤ x - "pitch": 1.0, // 0 ≤ x - "rate": 1.0 // 0 ≤ x - }, - - // Uses amplification to create a shuddering effect, where the volume quickly oscillates. - // Example: https://en.wikipedia.org/wiki/File:Fuse_Electronics_Tremolo_MK-III_Quick_Demo.ogv - "tremolo": { - "frequency": 2.0, // 0 < x - "depth": 0.5 // 0 < x ≤ 1 - }, - - // Similar to tremolo. While tremolo oscillates the volume, vibrato oscillates the pitch. - "vibrato": { - "frequency": 2.0, // 0 < x ≤ 14 - "depth": 0.5 // 0 < x ≤ 1 - }, - - // Rotates the sound around the stereo channels/user headphones aka Audio Panning. It can produce an effect similar to: https://youtu.be/QB9EB8mTKcc (without the reverb) - "rotation": { - "rotationHz": 0 // The frequency of the audio rotating around the listener in Hz. 0.2 is similar to the example video above. - }, - - // Distortion effect. It can generate some pretty unique audio effects. - "distortion": { - "sinOffset": 0.0, - "sinScale": 1.0, - "cosOffset": 0.0, - "cosScale": 1.0, - "tanOffset": 0.0, - "tanScale": 1.0, - "offset": 0.0, - "scale": 1.0 - } - - // Mixes both channels (left and right), with a configurable factor on how much each channel affects the other. - // With the defaults, both channels are kept independent from each other. - // Setting all factors to 0.5 means both channels get the same audio. - "channelMix": { - "leftToLeft": 1.0, - "leftToRight": 0.0, - "rightToLeft": 0.0, - "rightToRight": 1.0, - } - - // Higher frequencies get suppressed, while lower frequencies pass through this filter, thus the name low pass. - // Any smoothing values equal to, or less than 1.0 will disable the filter. - "lowPass": { - "smoothing": 20.0 // 1.0 < x - } + "op": "event", + "type": "...", + "guildId": "...", + ... } ``` -#### Destroy a player +
-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. +##### Event Types -```json -{ - "op": "destroy", - "guildId": "..." -} -``` +| Event Type | Description | +|-----------------------------------------------|--------------------------------------------------------------------------| +| [TrackStartEvent](#trackstartevent) | Emitted when a track starts playing | +| [TrackEndEvent](#trackendevent) | Emitted when a track ends | +| [TrackExceptionEvent](#trackexceptionevent) | Emitted when a track throws an exception | +| [TrackStuckEvent](#trackstuckevent) | Emitted when a track gets stuck while playing | +| [WebSocketClosedEvent](#websocketclosedevent) | Emitted when the websocket connection to Discord voice servers is closed | -### Incoming messages +##### TrackStartEvent -See [LavalinkSocket.java](https://github.com/freyacodes/lavalink-client/blob/master/src/main/java/lavalink/client/io/LavalinkSocket.java) for client implementation +Emitted when a track starts playing. + +| Field | Type | Description | +|--------------|--------|------------------------------------------------------------------------------------------------------| +| encodedTrack | string | The base64 encoded track that started playing | +| track | string | The base64 encoded track that started playing (DEPRECATED as of v3.7.0 and marked for removal in v4) | + +
+Example Payload -This event includes: -* Unix timestamp in milliseconds. -* Track position in milliseconds. Omitted if not playing anything. -* `connected` is true when connected to the voice gateway. -* `ping` represents the number of milliseconds between heartbeat and ack. Could be `-1` if not connected. ```json { - "op": "playerUpdate", - "guildId": "...", - "state": { - "time": 1500467109, - "position": 60000, - "connected": true, - "ping": 0 - } + "op": "event", + "type": "TrackStartEvent", + "guildId": "...", + "encodedTrack": "...", + "track": "..." } ``` -A collection of stats sent every minute. +
+ +--- + +##### TrackEndEvent + +Emitted when a track ends. + +| Field | Type | Description | +|--------------|-------------------------------------|----------------------------------------------------------------------------------------------------| +| encodedTrack | string | The base64 encoded track that ended playing | +| track | string | The base64 encoded track that ended playing (DEPRECATED as of v3.7.0 and marked for removal in v4) | +| reason | [TrackEndReason](#track-end-reason) | The reason the track ended | + +##### Track End Reason + +| Reason | Description | May Start Next | +|---------------|----------------------------|----------------| +| `FINISHED` | The track finished playing | true | +| `LOAD_FAILED` | The track failed to load | true | +| `STOPPED` | The track was stopped | false | +| `REPLACED` | The track was replaced | false | +| `CLEANUP` | The track was cleaned up | false | + +
+Example Payload ```json { - "op": "stats", - ... + "op": "event", + "type": "TrackEndEvent", + "guildId": "...", + "encodedTrack": "...", + "track": "...", + "reason": "FINISHED" } ``` -Example implementation of stats: -```java -players = json.getInt("players"); -playingPlayers = json.getInt("playingPlayers"); -uptime = json.getLong("uptime"); +
-memFree = json.getJSONObject("memory").getInt("free"); -memUsed = json.getJSONObject("memory").getInt("used"); -memAllocated = json.getJSONObject("memory").getInt("allocated"); -memReservable = json.getJSONObject("memory").getInt("reservable"); +--- -cpuCores = json.getJSONObject("cpu").getInt("cores"); -systemLoad = json.getJSONObject("cpu").getDouble("systemLoad"); -lavalinkLoad = json.getJSONObject("cpu").getDouble("lavalinkLoad"); +##### TrackExceptionEvent -JSONObject frames = json.optJSONObject("frameStats"); +Emitted when a track throws an exception. -if (frames != null) { - avgFramesSentPerMinute = frames.getInt("sent"); - avgFramesNulledPerMinute = frames.getInt("nulled"); - avgFramesDeficitPerMinute = frames.getInt("deficit"); -} -``` +| Field | Type | Description | +|--------------|---------------------------------------|----------------------------------------------------------------------------------------------------------| +| encodedTrack | string | The base64 encoded track that threw the exception | +| track | string | The base64 encoded track that threw the exception (DEPRECATED as of v3.7.0 and marked for removal in v4) | +| exception | [Exception](#exception-object) object | The occurred exception | -Server emitted an event. See the client implementation below. -```json -{ - "op": "event", - "type": "..." -} -``` - -```java -/** - * Implementation details: - * The only events extending {@link lavalink.client.player.event.PlayerEvent} produced by the remote server are these: - * 1. TrackStartEvent - * 2. TrackEndEvent - * 3. TrackExceptionEvent - * 4. TrackStuckEvent - * - * 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")); - PlayerEvent event = null; - - switch (json.getString("type")) { - case "TrackStartEvent": - event = new TrackStartEvent(player, - LavalinkUtil.toAudioTrack(json.getString("track")) - ); - break; - case "TrackEndEvent": - event = new TrackEndEvent(player, - LavalinkUtil.toAudioTrack(json.getString("track")), - AudioTrackEndReason.valueOf(json.getString("reason")) - ); - break; - case "TrackExceptionEvent": - JSONObject jsonEx = json.getJSONObject("exception"); - event = new TrackExceptionEvent(player, - LavalinkUtil.toAudioTrack(json.getString("track")), - new FriendlyException( - jsonEx.getString("message"), - FriendlyException.Severity.valueOf(jsonEx.getString("severity")), - new RuntimeException(jsonEx.getString("cause")) - ) - ); - break; - case "TrackStuckEvent": - event = new TrackStuckEvent(player, - LavalinkUtil.toAudioTrack(json.getString("track")), - json.getLong("thresholdMs") - ); - break; - default: - log.warn("Unexpected event type: " + json.getString("type")); - break; - } +##### Exception Object - if (event != null) player.emitEvent(event); -} -``` +| Field | Type | Description | +|----------|-----------------------|-------------------------------| +| message | ?string | The message of the exception | +| severity | [Severity](#severity) | The severity of the exception | +| cause | string | The cause of the exception | -See also: [AudioTrackEndReason.java](https://github.com/sedmelluq/lavaplayer/blob/master/main/src/main/java/com/sedmelluq/discord/lavaplayer/track/AudioTrackEndReason.java) +##### Severity -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). +| Severity | Description | +|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `COMMON` | The cause is known and expected, indicates that there is nothing wrong with the library itself | +| `SUSPICIOUS` | The cause might not be exactly known, but is possibly caused by outside factors. For example when an outside service responds in a format that we do not expect | +| `FATAL` | If the probable cause is an issue with the library or when there is no way to tell what the cause might be. This is the default level and other levels are used in cases where the thrower has more in-depth knowledge about the error | + +
+Example Payload ```json { - "op": "event", - "type": "WebSocketClosedEvent", - "guildId": "...", - "code": 4006, - "reason": "Your session is no longer valid.", - "byRemote": true + "op": "event", + "type": "TrackExceptionEvent", + "guildId": "...", + "encodedTrack": "...", + "track": "...", + "exception": { + "message": "...", + "severity": "COMMON", + "cause": "..." + } } ``` -### Track Loading API -The REST api is used to resolve audio tracks for use with the `play` op. -``` -GET /loadtracks?identifier=dQw4w9WgXcQ -Authorization: youshallnotpass -``` +
-Response: -```json -{ - "loadType": "TRACK_LOADED", - "playlistInfo": {}, - "tracks": [ - { - "track": "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==", - "info": { - "identifier": "dQw4w9WgXcQ", - "isSeekable": true, - "author": "RickAstleyVEVO", - "length": 212000, - "isStream": false, - "position": 0, - "title": "Rick Astley - Never Gonna Give You Up", - "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", - "sourceName": "youtube" - } - } - ] -} -``` +--- + +##### TrackStuckEvent + +Emitted when a track gets stuck while playing. + +| Field | Type | Description | +|--------------|--------|-------------------------------------------------------------------------------------------------| +| encodedTrack | string | The base64 encoded track that got stuck | +| track | string | The base64 encoded track that got stuck (DEPRECATED as of v3.7.0 and marked for removal in v4) | +| thresholdMs | int | The threshold in milliseconds that was exceeded | + +
+Example Payload -If the identifier leads to a playlist, `playlistInfo` will contain two properties, `name` and `selectedTrack`(-1 if no selectedTrack found) ```json { - "loadType": "PLAYLIST_LOADED", - "playlistInfo": { - "name": "Example YouTube Playlist", - "selectedTrack": 3 - }, - "tracks": [ - ... - ] + "op": "event", + "type": "TrackStuckEvent", + "guildId": "...", + "encodedTrack": "...", + "track": "...", + "thresholdMs": 123456789 } ``` -Additionally, in every `/loadtracks` response, a `loadType` property is returned which can be used to judge the response from Lavalink properly. It can be one of the following: -* `TRACK_LOADED` - Returned when a single track is loaded. -* `PLAYLIST_LOADED` - Returned when a playlist is loaded. -* `SEARCH_RESULT` - Returned when a search result is made (i.e `ytsearch: some song`). -* `NO_MATCHES` - Returned if no matches/sources could be found for a given identifier. -* `LOAD_FAILED` - Returned if Lavaplayer failed to load something for some reason. +
+ +--- + +##### WebSocketClosedEvent -If the loadType is `LOAD_FAILED`, the response will contain an `exception` object with `message` and `severity` properties. -`message` is a string detailing why the track failed to load, and is okay to display to end-users. Severity represents how common the error is. -A severity level of `COMMON` indicates that the error is non-fatal and that the issue is not from Lavalink itself. +Emitted when an audio WebSocket (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). + +| Field | Type | Description | +|----------|--------|-----------------------------------------------------------------------------------------------------------------------------------| +| code | int | The [Discord close event code](https://discord.com/developers/docs/topics/opcodes-and-status-codes#voice-voice-close-event-codes) | +| reason | string | The close reason | +| byRemote | bool | If the connection was closed by Discord | + +
+Example Payload ```json { - "loadType": "LOAD_FAILED", - "playlistInfo": {}, - "tracks": [], - "exception": { - "message": "The uploader has not made this video available in your country.", - "severity": "COMMON" - } + "op": "event", + "type": "WebSocketClosedEvent", + "guildId": "...", + "code": 4006, + "reason": "Your session is no longer valid.", + "byRemote": true } ``` -#### Track Searching -Lavalink supports searching via YouTube, YouTube Music, and Soundcloud. To search, you must prefix your identifier with `ytsearch:`, `ytmsearch:`, or `scsearch:`respectively. +
+ +--- -When a search prefix is used, the returned `loadType` will be `SEARCH_RESULT`. Note that, disabling the respective source managers renders these search prefixes redundant. Plugins may also implement prefixes to allow for more search engines. +### REST API -### Track Decoding API +Lavalink exposes a REST API to allow for easy control of the players. +Most routes require the Authorization header with the configured password. -Decode a single track into its info ``` -GET /decodetrack?track=QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA== Authorization: youshallnotpass ``` -Response: +Routes are prefixed with `/v3` as of `v3.7.0`. Routes without an API prefix are to be removed in v4. + +#### Error Responses + +When Lavalink encounters an error, it will respond with a JSON object containing more information about the error. Include the `trace=true` query param to also receive the full stack trace. + +| Field | Type | Description | +|-----------|--------|-----------------------------------------------------------------------------| +| timestamp | int | The timestamp of the error in milliseconds since the epoch | +| status | int | The HTTP status code | +| error | string | The HTTP status code message | +| trace? | string | The stack trace of the error when `trace=true` as query param has been sent | +| message | string | The error message | +| path | string | The request path | + +
+Example Payload + ```json { - "identifier": "dQw4w9WgXcQ", - "isSeekable": true, - "author": "RickAstleyVEVO", - "length": 212000, - "isStream": false, - "position": 0, - "title": "Rick Astley - Never Gonna Give You Up", - "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", - "sourceName": "youtube" + "timestamp": 1667857581613, + "status": 404, + "error": "Not Found", + "trace": "...", + "message": "Session not found", + "path": "/v3/sessions/xtaug914v9k5032f/players/817327181659111454" } ``` -Decode multiple tracks into info their info -``` -POST /decodetracks -Authorization: youshallnotpass -``` +
-Request: -```json -[ - "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==", - ... -] -``` +#### Get Players -Response: -```json -[ - { - "track": "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==", - "info": { - "identifier": "dQw4w9WgXcQ", - "isSeekable": true, - "author": "RickAstleyVEVO", - "length": 212000, - "isStream": false, - "position": 0, - "title": "Rick Astley - Never Gonna Give You Up", - "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", - "sourceName": "youtube" - } - }, - ... -] -``` ---- +Returns a list of players in this specific session. -### Get list of plugins -Request information about the plugins running on Lavalink, if any. ``` -GET /plugins -Authorization: youshallnotpass +GET /v3/sessions/{sessionId}/players ``` -Response: +##### Player + +| Field | Type | Description | +|---------|------------------------------------|-------------------------------------------------------| +| guildId | string | The guild id of the player | +| track | ?[Track](#track) object | The current playing track | +| volume | int | The volume of the player, range 0-1000, in percentage | +| paused | bool | Whether the player is paused | +| voice | [Voice State](#voice-state) object | The voice state of the player | +| filters | [Filters](#filters) object | The filters used by the player | + +##### Track + +| Field | Type | Description | +|---------|----------------------------------|--------------------------------------------------------------------------------------| +| encoded | string | The base64 encoded track data | +| track | string | The base64 encoded track data (DEPRECATED as of v3.7.0 and marked for removal in v4) | +| info | [Track Info](#track-info) object | Info about the track | + +##### Track Info + +| Field | Type | Description | +|------------|---------|------------------------------------| +| identifier | string | The track identifier | +| isSeekable | bool | Whether the track is seekable | +| author | string | The track author | +| length | int | The track length in milliseconds | +| isStream | bool | Whether the track is a stream | +| position | int | The track position in milliseconds | +| title | string | The track title | +| uri | ?string | The track uri | +| sourceName | string | The track source name | + +##### Voice State + +| Field | Type | Description | +|------------|--------|---------------------------------------------------------------------------------------------| +| token | string | The Discord voice token to authenticate with | +| endpoint | string | The Discord voice endpoint to connect to | +| sessionId | string | The Discord voice session id to authenticate with | +| connected? | bool | Whether the player is connected. Response only | +| ping? | int | Roundtrip latency in milliseconds to the voice gateway (-1 if not connected). Response only | + +`token`, `endpoint`, and `sessionId` are the 3 required values for connecting to one of Discord's voice servers. +`sessionId` is provided by the Voice State Update event sent by Discord, whereas the `endpoint` and `token` are provided +with the Voice Server Update. Please refer to https://discord.com/developers/docs/topics/gateway-events#voice + +
+Example Payload + ```yaml [ { - "name": "some-plugin", - "version": "1.0.0" - }, - { - "name": "foo-plugin", - "version": "1.2.3" + "guildId": "...", + "track": { + "encoded": "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==", + "identifier": "dQw4w9WgXcQ", + "isSeekable": true, + "author": "RickAstleyVEVO", + "length": 212000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up", + "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "sourceName": "youtube" + }, + "volume": 100, + "paused": false, + "voice": { + "token": "...", + "endpoint": "...", + "sessionId": "...", + "connected": true, + "ping": 10 + }, + "filters": { ... } } ] ``` -### RoutePlanner API +
-Additionally, there are a few REST endpoints for the ip rotation extension +--- -#### Get RoutePlanner status +#### Get Player + +Returns the player for this guild in this session. ``` -GET /routeplanner/status -Authorization: youshallnotpass +GET /v3/sessions/{sessionId}/players/{guildId} ``` Response: -```json +[Player Object](#Player) +
+Example Payload + +```yaml { - "class": "RotatingNanoIpRoutePlanner", - "details": { - "ipBlock": { - "type": "Inet6Address", - "size": "1208925819614629174706176" - }, - "failingAddresses": [ - { - "address": "/1.0.0.0", - "failingTimestamp": 1573520707545, - "failingTime": "Mon Nov 11 20:05:07 EST 2019" - } - ], - "blockIndex": "0", - "currentAddressIndex": "36792023813" - } + "guildId": "...", + "track": { + "encoded": "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==", + "identifier": "dQw4w9WgXcQ", + "isSeekable": true, + "author": "RickAstleyVEVO", + "length": 212000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up", + "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "sourceName": "youtube" + }, + "volume": 100, + "paused": false, + "voice": { + "token": "...", + "endpoint": "...", + "sessionId": "...", + "connected": true, + "ping": 10 + }, + "filters": { ... } } ``` -The response is different based on each route planner. -Fields which are always present are: `class`, `details.ipBlock` and -`details.failingAddresses`. If no route planner is set, both `class` and -`details` will be null, and the other endpoints will result in status 500. - -The following classes have additional detail fields: +
-#### RotatingIpRoutePlanner - -`details.rotateIndex` String containing the number of rotations which happened -since the restart of Lavalink +--- -`details.ipIndex` String containing the current offset in the block +#### Update Player -`details.currentAddress` The currently used ip address +Updates or creates the player for this guild if it doesn't already exist. -#### NanoIpRoutePlanner +``` +PATCH /v3/sessions/{sessionId}/players/{guildId}?noReplace=true +``` -`details.currentAddressIndex` long representing the current offset in the ip -block +Query Params: -#### RotatingNanoIpRoutePlanner +| Field | Type | Description | +|-----------|------|------------------------------------------------------------------------------| +| noReplace | bool | Whether to replace the current track with the new track. Defaults to `false` | -`details.blockIndex` String containing the the information in which /64 block ips -are chosen. This number increases on each ban. +Request: -`details.currentAddressIndex` long representing the current offset in the ip -block. +| Field | Type | Description | +|-----------------|------------------------------------|-------------------------------------------------------------------------------| +| encodedTrack? * | ?string | The encoded track base64 to play. `null` stops the current track | +| identifier? * | string | The track identifier to play | +| position? | int | The track position in milliseconds | +| endTime? | int | The track end time in milliseconds | +| volume? | int | The player volume from 0 to 1000 | +| paused? | bool | Whether the player is paused | +| filters? | [Filters](#filters) object | The new filters to apply. This will override all previously applied filters | +| voice? | [Voice State](#voice-state) object | Information required for connecting to Discord, without `connected` or `ping` | -#### Unmark a failed address +**\* Note that `encodedTrack` and `identifier` are mutually exclusive.** -``` -POST /routeplanner/free/address -Host: localhost:8080 -Authorization: youshallnotpass -``` +When `identifier` is used, Lavalink will try to resolve the identifier as a single track. An HTTP `400` error is returned when resolving a playlist, search result, or no tracks. -Request Body: +
+Example Payload -```json +```yaml { - "address": "1.0.0.1" + "encodedTrack": "...", + "identifier": "...", + "startTime": 0, + "endTime": 0, + "volume": 100, + "position": 32400, + "paused": false, + "filters": { ... }, + "voice": { + "token": "...", + "endpoint": "...", + "sessionId": "..." + } } ``` +
+ Response: -204 - No Content +[Player Object](#Player) -#### Unmark all failed address +
+Example Payload -``` -POST /routeplanner/free/all -Host: localhost:8080 -Authorization: youshallnotpass -``` +```yaml +{ + "guildId": "...", + "track": { + "encoded": "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==", + "info": { + "identifier": "dQw4w9WgXcQ", + "isSeekable": true, + "author": "RickAstleyVEVO", + "length": 212000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up", + "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "sourceName": "youtube" + } + }, + "volume": 100, + "paused": false, + "voice": { + "token": "...", + "endpoint": "...", + "sessionId": "...", + "connected": true, + "ping": 10 + }, + "filters": { ... } +} +``` + +
+ +--- + +#### Filters + +Filters are used in above requests and look like this + +| Field | Type | Description | +|-------------|------------------------------------|-----------------------------------------------------------------------------------------------------| +| volume? | float | Lets you adjust the player volume from 0.0 to 5.0 where 1.0 is 100%. Values >1.0 may cause clipping | +| equalizer? | array of [Equalizers](#equalizer) | Lets you adjust 15 different bands | +| karaoke? | [Karaoke](#karaoke) object | Lets you eliminate part of a band, usually targeting vocals | +| timescale? | [Timescale](#timescale) object | Lets you change the speed, pitch, and rate | +| tremolo? | [Tremolo](#tremolo) object | Lets you create a shuddering effect, where the volume quickly oscillates | +| vibrato? | [Vibrato](#vibrato) object | Lets you create a shuddering effect, where the pitch quickly oscillates | +| rotation? | [Rotation](#rotation) object | Lets you rotate the sound around the stereo channels/user headphones aka Audio Panning | +| distortion? | [Distortion](#distortion) object | Lets you distort the audio | +| channelMix? | [Channel Mix](#channel-mix) object | Lets you mix both channels (left and right) | +| lowPass? | [Low Pass](#low-pass) object | Lets you filter higher frequencies | + +##### Equalizer + +There are 15 bands (0-14) 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. + +
+Band Frequencies + +| Band | Frequency | +|------|-----------| +| 0 | 25 Hz | +| 1 | 40 Hz | +| 2 | 63 Hz | +| 3 | 100 Hz | +| 4 | 160 Hz | +| 5 | 250 Hz | +| 6 | 400 Hz | +| 7 | 630 Hz | +| 8 | 1000 Hz | +| 9 | 1600 Hz | +| 10 | 2500 Hz | +| 11 | 4000 Hz | +| 12 | 6300 Hz | +| 13 | 10000 Hz | +| 14 | 16000 Hz | + +
+ +| Field | Type | Description | +|-------|-------|-------------------------| +| bands | int | The band (0 to 14) | +| gain | float | The gain (-0.25 to 1.0) | + +##### Karaoke + +Uses equalization to eliminate part of a band, usually targeting vocals. + +| Field | Type | Description | +|--------------|-------|-------------------------------------------------------------------------| +| level? | float | The level (0 to 1.0 where 0.0 is no effect and 1.0 is full effect) | +| monoLevel? | float | The mono level (0 to 1.0 where 0.0 is no effect and 1.0 is full effect) | +| filterBand? | float | The filter band | +| filterWidth? | float | The filter width | + +##### Timescale + +Changes the speed, pitch, and rate. All default to 1.0. + +| Field | Type | Description | +|--------|-------|----------------------------| +| speed? | float | The playback speed 0.0 ≤ x | +| pitch? | float | The pitch 0.0 ≤ x | +| rate? | float | The rate 0.0 ≤ x | + +##### Tremolo + +Uses amplification to create a shuddering effect, where the volume quickly oscillates. +https://en.wikipedia.org/wiki/File:Fuse_Electronics_Tremolo_MK-III_Quick_Demo.ogv + +| Field | Type | Description | +|------------|-------|---------------------------------| +| frequency? | float | The frequency 0.0 < x | +| depth? | float | The tremolo depth 0.0 < x ≤ 1.0 | + +##### Vibrato + +Similar to tremolo. While tremolo oscillates the volume, vibrato oscillates the pitch. + +| Field | Type | Description | +|------------|-------|---------------------------------| +| frequency? | float | The frequency 0.0 < x ≤ 14.0 | +| depth? | float | The vibrato depth 0.0 < x ≤ 1.0 | + +##### Rotation + +Rotates the sound around the stereo channels/user headphones aka Audio Panning. It can produce an effect similar to https://youtu.be/QB9EB8mTKcc (without the reverb) + +| Field | Type | Description | +|-------------|-------|----------------------------------------------------------------------------------------------------------| +| rotationHz? | float | The frequency of the audio rotating around the listener in Hz. 0.2 is similar to the example video above | + +##### Distortion + +Distortion effect. It can generate some pretty unique audio effects. + +| Field | Type | Description | +|------------|-------|----------------| +| sinOffset? | float | The sin offset | +| sinScale? | float | The sin scale | +| cosOffset? | float | The cos offset | +| cosScale? | float | The cos scale | +| tanOffset? | float | The tan offset | +| tanScale? | float | The tan scale | +| offset? | float | The offset | +| scale? | float | The scale | + +##### Channel Mix + +Mixes both channels (left and right), with a configurable factor on how much each channel affects the other. +With the defaults, both channels are kept independent of each other. +Setting all factors to 0.5 means both channels get the same audio. + +| Field | Type | Description | +|---------------|-------|-------------------------------------------------------| +| leftToLeft? | float | The left to left channel mix factor (0.0 ≤ x ≤ 1.0) | +| leftToRight? | float | The left to right channel mix factor (0.0 ≤ x ≤ 1.0) | +| rightToLeft? | float | The right to left channel mix factor (0.0 ≤ x ≤ 1.0) | +| rightToRight? | float | The right to right channel mix factor (0.0 ≤ x ≤ 1.0) | + +##### Low Pass + +Higher frequencies get suppressed, while lower frequencies pass through this filter, thus the name low pass. +Any smoothing values equal to, or less than 1.0 will disable the filter. + +| Field | Type | Description | +|------------|-------|--------------------------------| +| smoothing? | float | The smoothing factor (1.0 < x) | + +
+Example Payload + +```json +{ + "volume": 1.0, + "equalizer": [ + { + "band": 0, + "gain": 0.2 + } + ], + "karaoke": { + "level": 1.0, + "monoLevel": 1.0, + "filterBand": 220.0, + "filterWidth": 100.0 + }, + "timescale": { + "speed": 1.0, + "pitch": 1.0, + "rate": 1.0 + }, + "tremolo": { + "frequency": 2.0, + "depth": 0.5 + }, + "vibrato": { + "frequency": 2.0, + "depth": 0.5 + }, + "rotation": { + "rotationHz": 0 + }, + "distortion": { + "sinOffset": 0.0, + "sinScale": 1.0, + "cosOffset": 0.0, + "cosScale": 1.0, + "tanOffset": 0.0, + "tanScale": 1.0, + "offset": 0.0, + "scale": 1.0 + }, + "channelMix": { + "leftToLeft": 1.0, + "leftToRight": 0.0, + "rightToLeft": 0.0, + "rightToRight": 1.0 + }, + "lowPass": { + "smoothing": 20.0 + } +} +``` + +
+ +--- + +#### Destroy Player + +Destroys the player for this guild in this session. + +``` +DELETE /v3/sessions/{sessionId}/players/{guildId} +``` Response: @@ -627,59 +978,878 @@ Response: --- -All REST responses from Lavalink include a `Lavalink-Api-Version` header. +#### Update Session + +Updates the session with a resuming key and timeout. + +``` +PATCH /v3/sessions/{sessionId} +``` + +Request: + +| Field | Type | Description | +|--------------|---------|----------------------------------------------------------| +| resumingKey? | ?string | The resuming key to be able to resume this session later | +| timeout? | int | The timeout in seconds (default is 60s) | + +
+Example Payload + +```json +{ + "resumingKey": "...", + "timeout": 0 +} +``` + +
+ +Response: + +| Field | Type | Description | +|-------------|---------|----------------------------------------------------------| +| resumingKey | ?string | The resuming key to be able to resume this session later | +| timeout | int | The timeout in seconds (default is 60s) | + +
+Example Payload + +```json +{ + "resumingKey": "...", + "timeout": 60 +} +``` + +
+ +--- + +#### Track Loading + +This endpoint is used to resolve audio tracks for use with the [Update Player](#update-player) endpoint. +> `/loadtracks?identifier=dQw4w9WgXcQ` is deprecated and marked for removal in v4 + +``` +GET /v3/loadtracks?identifier=dQw4w9WgXcQ +``` + +Response: + +##### Track Loading Result + +| Field | Type | Description | Required Load Type | +|--------------|----------------------------------------|-----------------------------------------------------------|----------------------------------------------------| +| loadType | [LoadResultType](#load-result-type) | The type of the result | | +| playlistInfo | [Playlist Info](#playlist-info) object | Additional info if the the load type is `PLAYLIST_LOADED` | `PLAYLIST_LOADED` | +| tracks | array of [Tracks](#track) | All tracks which have been loaded | `TRACK_LOADED`, `PLAYLIST_LOADED`, `SEARCH_RESULT` | +| exception? | [Exception](#exception-object) object | The [Exception](#exception-object) this load failed with | `LOAD_FAILED` | + +##### Load Result Type + +| Load Result Type | Description | +|-------------------|----------------------------------------------| +| `TRACK_LOADED` | A track has been loaded | +| `PLAYLIST_LOADED` | A playlist has been loaded | +| `SEARCH_RESULT` | A search result has been loaded | +| `NO_MATCHES` | There has been no matches to your identifier | +| `LOAD_FAILED` | Loading has failed | + +##### Playlist Info + +| Field | Type | Description | +|----------------|--------|------------------------------------------------------------------| +| name? | string | The name of the loaded playlist | +| selectedTrack? | int | The selected track in this Playlist (-1 if no track is selected) | + +
+Track Loaded Example Payload + +```yaml +{ + "loadType": "TRACK_LOADED", + "playlistInfo": {}, + "tracks": [ + { + "encoded": "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==", + "track": "...", # Same as encoded, removed in /v4 + "info": { + "identifier": "dQw4w9WgXcQ", + "isSeekable": true, + "author": "RickAstleyVEVO", + "length": 212000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up", + "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "sourceName": "youtube" + } + } + ] +} +``` + +
+ +
+Playlist Loaded Example Payload + +```yaml +{ + "loadType": "PLAYLIST_LOADED", + "playlistInfo": { + "name": "Example YouTube Playlist", + "selectedTrack": 3 + }, + "tracks": [ + ... + ] +} +``` + +
+ +
+Search Result Example Payload + +```yaml +{ + "loadType": "SEARCH_RESULT", + "playlistInfo": {}, + "tracks": [ + ... + ] +} +``` + +
+ +
+No Matches Example Payload + +```json +{ + "loadType": "NO_MATCHES", + "playlistInfo": {}, + "tracks": [] +} +``` + +
+ +
+Load Failed Example Payload + +```json +{ + "loadType": "LOAD_FAILED", + "playlistInfo": {}, + "tracks": [], + "exception": { + "message": "The uploader has not made this video available in your country.", + "severity": "COMMON" + } +} +``` + +
+ +#### Track Searching + +Lavalink supports searching via YouTube, YouTube Music, and Soundcloud. To search, you must prefix your identifier with `ytsearch:`, `ytmsearch:`, or `scsearch:`respectively. + +When a search prefix is used, the returned `loadType` will be `SEARCH_RESULT`. Note that, disabling the respective source managers renders these search prefixes useless. Plugins may also implement prefixes to allow for more search engines. + +--- + +#### Track Decoding + +Decode a single track into its info, where `BASE64` is the encoded base64 data. + +``` +GET /v3/decodetrack?encodedTrack=BASE64 +``` + +Response: + +[Track Object](#track) + +
+Example Payload + +```yaml +{ + "encoded": "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==", + "track": "...", # Same as encoded, removed in /v4 + "info": { + "identifier": "dQw4w9WgXcQ", + "isSeekable": true, + "author": "RickAstleyVEVO", + "length": 212000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up", + "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "sourceName": "youtube" + } +} +``` + +
+ +--- + +Decodes multiple tracks into their info + +``` +POST /v3/decodetracks +``` + +Request: + +Array of track data strings + +
+Example Payload + +```yaml +[ + "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==", + ... +] +``` + +
-### Resuming Lavalink sessions +Response: + +Array of [Track Objects](#track) -What happens after your client disconnects is dependent on whether or not the session has been configured for resuming. +
+Example Payload + +```yaml +[ + { + "encoded": "QAAAjQIAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADlJpY2tBc3RsZXlWRVZPAAAAAAADPCAAC2RRdzR3OVdnWGNRAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZFF3NHc5V2dYY1EAB3lvdXR1YmUAAAAAAAAAAA==", + "track": "...", # Same as encoded, removed in /v4 + "info": { + "identifier": "dQw4w9WgXcQ", + "isSeekable": true, + "author": "RickAstleyVEVO", + "length": 212000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up", + "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "sourceName": "youtube" + } + }, + ... +] +``` + +
+ +--- + +#### Get Lavalink info + +Request Lavalink information. + +``` +GET /v3/info +``` + +Response: + +##### Info Response + +| Field | Type | Description | +|----------------|------------------------------------|-----------------------------------------------------------------| +| version | [Version](#version-object) object | The version of this Lavalink server | +| buildTime | int | The millisecond unix timestamp when this Lavalink jar was built | +| git | [Git](#git-object) object | The git information of this Lavalink server | +| jvm | string | The JVM version this Lavalink server runs on | +| lavaplayer | string | The Lavaplayer version being used by this server | +| sourceManagers | array of strings | The enabled source managers for this server | +| filters | array of strings | The enabled filters for this server | +| plugins | array of [Plugins](#plugin-object) | The enabled plugins for this server | + +##### Version Object + +| Field | Type | Description | +|------------|---------|------------------------------------------------------------------------------------| +| semver | string | The full version string of this Lavalink server | +| major | int | The major version of this Lavalink server | +| minor | int | The minor version of this Lavalink server | +| patch | int | The patch version of this Lavalink server | +| preRelease | ?string | The pre-release version according to semver as a `.` separated list of identifiers | + +##### Git Object + +| Field | Type | Description | +|------------|--------|----------------------------------------------------------------| +| branch | string | The branch this Lavalink server was built | +| commit | string | The commit this Lavalink server was built | +| commitTime | int | The millisecond unix timestamp for when the commit was created | + +##### Plugin Object + +| Field | Type | Description | +|---------|--------|---------------------------| +| name | string | The name of the plugin | +| version | string | The version of the plugin | + +
+Example Payload + +```json +{ + "version": { + "string": "3.7.0-rc.1", + "major": 3, + "minor": 7, + "patch": 0, + "preRelease": "rc.1" + }, + "buildTime": 1664223916812, + "git": { + "branch": "master", + "commit": "85c5ab5", + "commitTime": 1664223916812 + }, + "jvm": "18.0.2.1", + "lavaplayer": "1.3.98.4-original", + "sourceManagers": [ + "youtube", + "soundcloud" + ], + "filters": [ + "equalizer", + "karaoke", + "timescale", + "channelMix" + ], + "plugins": [ + { + "name": "some-plugin", + "version": "1.0.0" + }, + { + "name": "foo-plugin", + "version": "1.2.3" + } + ] +} +``` + +
+ +--- + +#### Get Lavalink stats + +Request Lavalink statistics. + +``` +GET /v3/stats +``` + +Response: + +`frameStats` is always `null` for this endpoint. +[Stats Object](#stats-object) + +
+Example Payload + +```json +{ + "players": 1, + "playingPlayers": 1, + "uptime": 123456789, + "memory": { + "free": 123456789, + "used": 123456789, + "allocated": 123456789, + "reservable": 123456789 + }, + "cpu": { + "cores": 4, + "systemLoad": 0.5, + "lavalinkLoad": 0.5 + }, + "frameStats": null +} +``` + +
+ +--- + +#### Get Plugins (DEPRECATED) + +Request information about the plugins running on Lavalink, if any. +> `/plugins` is deprecated and marked for removal in v4, use [`/info`](#get-lavalink-info) instead + +``` +GET /plugins +``` + +Response: + +```yaml +[ + { + "name": "some-plugin", + "version": "1.0.0" + }, + { + "name": "foo-plugin", + "version": "1.2.3" + } +] +``` + +--- + +#### Get Lavalink version + +Request Lavalink version. + +``` +GET /version +``` + +Response: + +``` +3.7.0 +``` + +--- + +### RoutePlanner API + +Additionally, there are a few REST endpoints for the ip rotation extension. + +#### Get RoutePlanner status + +> `/routeplanner/status` is deprecated and marked for removal in v4 + +``` +GET /v3/routeplanner/status +``` + +Response: + +| Field | Type | Description | +|---------|---------------------------------------------|-----------------------------------------------------------------------| +| class | ?[Route Planner Type](#route-planner-types) | The name of the RoutePlanner implementation being used by this server | +| details | ?[Details](#details-object) object | The status details of the RoutePlanner | + +##### Route Planner Types + +| Route Planner Type | Description | +|------------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| `RotatingIpRoutePlanner` | IP address used is switched on ban. Recommended for IPv4 blocks or IPv6 blocks smaller than a /64. | +| `NanoIpRoutePlanner` | IP address used is switched on clock update. Use with at least 1 /64 IPv6 block. | +| `RotatingNanoIpRoutePlanner` | IP address used is switched on clock update, rotates to a different /64 block on ban. Use with at least 2x /64 IPv6 blocks. | + +##### Details Object + +| Field | Type | Description | Valid Types | +|---------------------|-------------------------------------------------------|---------------------------------------------------------------------------------------|----------------------------------------------------| +| ipBlock | [IP Block](#ip-block-object) object | The ip block being used | all | +| failingAddresses | array of [Failing Addresses](#failing-address-object) | The failing addresses | all | +| rotateIndex | string | The number of rotations | `RotatingIpRoutePlanner` | +| ipIndex | string | The current offset in the block | `RotatingIpRoutePlanner` | +| currentAddress | string | The current address being used | `RotatingIpRoutePlanner` | +| currentAddressIndex | string | The current current offset in the ip block | `NanoIpRoutePlanner`, `RotatingNanoIpRoutePlanner` | +| blockIndex | string | The information in which /64 block ips are chosen. This number increases on each ban. | `RotatingNanoIpRoutePlanner` | + +##### IP Block Object + +| Field | Type | Description | +|-------|---------------------------------|--------------------------| +| type | [IP Block Type](#ip-block-type) | The type of the ip block | +| size | string | The size of the ip block | + +##### IP Block Type + +| IP Block Type | Description | +|----------------|---------------------| +| `Inet4Address` | The ipv4 block type | +| `Inet6Address` | The ipv6 block type | + +##### Failing Address Object + +| Field | Type | Description | +|------------------|--------|----------------------------------------------------------| +| address | string | The failing address | +| failingTimestamp | int | The timestamp when the address failed | +| failingTime | string | The timestamp when the address failed as a pretty string | + +
+Example Payload + +```json +{ + "class": "RotatingNanoIpRoutePlanner", + "details": { + "ipBlock": { + "type": "Inet6Address", + "size": "1208925819614629174706176" + }, + "failingAddresses": [ + { + "address": "/1.0.0.0", + "failingTimestamp": 1573520707545, + "failingTime": "Mon Nov 11 20:05:07 EST 2019" + } + ], + "blockIndex": "0", + "currentAddressIndex": "36792023813" + } +} +``` + +
+ +--- + +#### Unmark a failed address + +> `/routeplanner/free/address` is deprecated and marked for removal in v4 + +``` +POST /v3/routeplanner/free/address +``` + +Request: + +| Field | Type | Description | +|---------|--------|-----------------------------------------------------------------------------| +| address | string | The address to unmark as failed. This address must be in the same ip block. | + +
+Example Payload + +```json +{ + "address": "1.0.0.1" +} +``` + +
+ +Response: + +204 - No Content + +--- + +#### Unmark all failed address + +> `/routeplanner/free/all` is deprecated and marked for removal in v4 + +``` +POST /v3/routeplanner/free/all +``` + +Response: + +204 - No Content + +--- + +### Resuming Lavalink Sessions + +What happens after your client disconnects is dependent on whether the session has been configured for resuming. * If resuming is disabled all voice connections are closed immediately. * If resuming is enabled all music will continue playing. You will then be able to resume your session, allowing you to control the players again. +To enable resuming, you must call the [Update Session](#update-session) endpoint with the `resumingKey` and `timeout`. + +
+Configure Resuming OP(DEPRECATED) + To enable resuming, you must send a `configureResuming` message. * `key` is the string you will need to send when resuming the session. Set to null to disable resuming altogether. Defaults to null. -* `timeout` is the number of seconds after disconnecting before the session is closed anyways. This is useful for avoiding accidental leaks. Defaults to `60` (seconds). +* `timeout` is the number of seconds after disconnecting before the session is closed anyway. This is useful for avoiding accidental leaks. Defaults to `60` (seconds). ```json { - "op": "configureResuming", - "key": "myResumeKey", - "timeout": 60 + "op": "configureResuming", + "key": "myResumeKey", + "timeout": 60 } ``` +
+ To resume a session, specify the resume key in your WebSocket handshake request headers: ``` Resume-Key: The resume key of the session you want to resume. ``` -You can tell if your session was resumed by looking at the handshake response header `Session-Resumed` which is either `true` or `false`: +You can tell if your session was resumed by looking at the handshake response header `Session-Resumed` which is either `true` or `false`. ``` Session-Resumed: true ``` +In case your websocket library doesn't support reading headers you can listen for the [ready op](#ready-op) and check the `resumed` field. + When a session is paused, any events that would normally have been sent is queued up. When the session is resumed, this -queue is then emptied and the events are then replayed. +queue is then emptied and the events are then replayed. + +--- ### Special notes -* When your shard's main WS connection dies, so does all your lavalink audio connections. - * This also includes resumes +* When your shard's main WS connection dies, so does all your Lavalink audio connections. + * This also includes resumes * If Lavalink-Server suddenly dies (think SIGKILL) the client will have to terminate any audio connections by sending this event: ```json -{"op":4,"d":{"self_deaf":false,"guild_id":"GUILD_ID_HERE","channel_id":null,"self_mute":false}} +{ + "op": 4, + "d": { + "self_deaf": false, + "guild_id": "GUILD_ID_HERE", + "channel_id": null, + "self_mute": false + } +} +``` + +--- + +### Outgoing messages (DEPRECATED) + +
+Show deprecated messages + +#### Provide a voice server update (DEPRECATED) + +> The `voiceUpdate` op is deprecated and marked for removal in v4, use [Update Player Endpoint](#update-player) with the `voice` json field instead. + +Provide an intercepted voice server update. This causes the server to connect to the voice channel. + +```yaml +{ + "op": "voiceUpdate", + "guildId": "...", + "sessionId": "...", + "event": { ... } +} ``` +#### Play a track (DEPRECATED) + +> The `play` op is deprecated and marked for removal in v4, use [Update Player Endpoint](#update-player) with the `track` or `identifier` json field instead. + +`startTime` is an optional setting that determines the number of milliseconds to offset the track by. Defaults to 0. + +`endTime` is an optional setting that determines at the number of milliseconds at which point the track should stop playing. Helpful if you only want to play a snippet of a bigger track. By default, the track plays until its end as per the encoded data. + +`volume` is an optional setting which changes the volume if provided. + +If `noReplace` is set to true, this operation will be ignored if a track is already playing or paused. This is an optional field. + +If `pause` is set to true, the playback will be paused. This is an optional field. + +```json +{ + "op": "play", + "guildId": "...", + "track": "...", + "startTime": "60000", + "endTime": "120000", + "volume": "100", + "noReplace": false, + "pause": false +} +``` + +#### Stop a player (DEPRECATED) + +> The `stop` op is deprecated and marked for removal in v4, use [Update Player Endpoint](#update-player) with the `track` json field as `null` instead. + +```json +{ + "op": "stop", + "guildId": "..." +} +``` + +#### Pause the playback (DEPRECATED) + +> The `pause` op is deprecated and marked for removal in v4, use [Update Player Endpoint](#update-player) with the `paused` json field instead. + +```json +{ + "op": "pause", + "guildId": "...", + "pause": true +} +``` + +#### Seek a track (DEPRECATED) + +> The `seek` op is deprecated and marked for removal in v4, use [Update Player Endpoint](#update-player) with the `position` json field instead. + +The position is in milliseconds. + +```json +{ + "op": "seek", + "guildId": "...", + "position": 60000 +} +``` + +#### Set player volume (DEPRECATED) + +> The `volume` op is deprecated and marked for removal in v4, use [Update Player Endpoint](#update-player) with the `volume` json field instead. + +Volume may range from 0 to 1000. 100 is default. + +```json +{ + "op": "volume", + "guildId": "...", + "volume": 125 +} +``` + +#### Using filters (DEPRECATED) + +> The `filters` op is deprecated and marked for removal in v4, use [Update Player Endpoint](#update-player) with the `filters` json field instead. + +The `filters` op sets the filters. All the filters are optional, and leaving them out of this message will disable them. + +Adding a filter can have adverse effects on performance. These filters force Lavaplayer to decode all audio to PCM, +even if the input was already in the Opus format that Discord uses. This means decoding and encoding audio that would +normally require very little processing. This is often the case with YouTube videos. + +JSON comments are for illustration purposes only, and will not be accepted by the server. + +Note that filters may take a moment to apply. + +```yaml +{ + "op": "filters", + "guildId": "...", + + // Float value where 1.0 is 100%. Values >1.0 may cause clipping + "volume": 1.0, // 0 ≤ x ≤ 5 + + // There are 15 bands (0-14) 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. + "equalizer": [ + { + "band": 0, // 0 ≤ x ≤ 14 + "gain": 0.2 // -0.25 ≤ x ≤ 1 + } + ], + + // Uses equalization to eliminate part of a band, usually targeting vocals. + "karaoke": { + "level": 1.0, + "monoLevel": 1.0, + "filterBand": 220.0, + "filterWidth": 100.0 + }, + + // Changes the speed, pitch, and rate. All default to 1. + "timescale": { + "speed": 1.0, // 0 ≤ x + "pitch": 1.0, // 0 ≤ x + "rate": 1.0 // 0 ≤ x + }, + + // Uses amplification to create a shuddering effect, where the volume quickly oscillates. + // Example https://en.wikipedia.org/wiki/File:Fuse_Electronics_Tremolo_MK-III_Quick_Demo.ogv + "tremolo": { + "frequency": 2.0, // 0 < x + "depth": 0.5 // 0 < x ≤ 1 + }, + + // Similar to tremolo. While tremolo oscillates the volume, vibrato oscillates the pitch. + "vibrato": { + "frequency": 2.0, // 0 < x ≤ 14 + "depth": 0.5 // 0 < x ≤ 1 + }, + + // Rotates the sound around the stereo channels/user headphones aka Audio Panning. It can produce an effect similar to https://youtu.be/QB9EB8mTKcc (without the reverb) + "rotation": { + "rotationHz": 0 // The frequency of the audio rotating around the listener in Hz. 0.2 is similar to the example video above. + }, + + // Distortion effect. It can generate some pretty unique audio effects. + "distortion": { + "sinOffset": 0.0, + "sinScale": 1.0, + "cosOffset": 0.0, + "cosScale": 1.0, + "tanOffset": 0.0, + "tanScale": 1.0, + "offset": 0.0, + "scale": 1.0 + } + + // Mixes both channels (left and right), with a configurable factor on how much each channel affects the other. + // With the defaults, both channels are kept independent from each other. + // Setting all factors to 0.5 means both channels get the same audio. + "channelMix": { + "leftToLeft": 1.0, + "leftToRight": 0.0, + "rightToLeft": 0.0, + "rightToRight": 1.0, + } + + // Higher frequencies get suppressed, while lower frequencies pass through this filter, thus the name low pass. + // Any smoothing values equal to, or less than 1.0 will disable the filter. + "lowPass": { + "smoothing": 20.0 // 1.0 < x + } +} +``` + +#### Destroy a player (DEPRECATED) + +> The `destroy` op is deprecated and marked for removal in v4, use [Destroy Player Endpoint](#destroy-player) instead. + +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. + +```json +{ + "op": "destroy", + "guildId": "..." +} +``` + +
+ +--- + # Common pitfalls -Admittedly Lavalink isn't inherently the most intuitive thing ever, and people tend to run into the same mistakes over again. Please double check the following if you run into problems developing your client and you can't connect to a voice channel or play audio: -1. Check that you are forwarding sendWS events to **Discord**. -2. Check that you are intercepting **VOICE_SERVER_UPDATE**s to **Lavalink**. Do not edit the event object from Discord. -3. Check that you aren't expecting to hear audio when you have forgotten to queue something up OR forgotten to join a voice channel. -4. Check that you are not trying to create a voice connection with your Discord library. -5. When in doubt, check the debug logfile at `/logs/debug.log`. +Admittedly Lavalink isn't inherently the most intuitive thing ever, and people tend to run into the same mistakes over again. Please double-check the following if you run into problems developing your client, and you can't connect to a voice channel or play audio: + +1. Check that you are intercepting **VOICE_SERVER_UPDATE**s and **VOICE_STATE_UPDATE**s to **Lavalink**. You only need the `endpoint`, `token`, and `session_id`. +2. Check that you aren't expecting to hear audio when you have forgotten to queue something up OR forgotten to join a voice channel. +3. Check that you are not trying to create a voice connection with your Discord library. +4. When in doubt, check the debug logfile at `/logs/debug.log`. diff --git a/LavalinkServer/application.yml.example b/LavalinkServer/application.yml.example index 16b7373f1..93ef2aecf 100644 --- a/LavalinkServer/application.yml.example +++ b/LavalinkServer/application.yml.example @@ -69,6 +69,15 @@ logging: root: INFO lavalink: INFO + request: + enabled: true + includeClientInfo: true + includeHeaders: false + includeQueryString: true + includePayload: true + maxPayloadLength: 10000 + + logback: rollingpolicy: max-file-size: 1GB diff --git a/LavalinkServer/build.gradle.kts b/LavalinkServer/build.gradle.kts index 3a3b13b4f..d3cb065e5 100644 --- a/LavalinkServer/build.gradle.kts +++ b/LavalinkServer/build.gradle.kts @@ -34,7 +34,10 @@ configurations { } dependencies { - implementation(projects.pluginApi) + implementation(projects.protocol) + implementation(projects.pluginApi) { + exclude(group = "org.springframework.boot", module = "spring-boot-starter-tomcat") + } implementation(libs.bundles.metrics) implementation(libs.bundles.spring) { @@ -59,7 +62,6 @@ dependencies { implementation(libs.sentry.logback) implementation(libs.oshi) implementation(libs.json) - implementation(libs.gson) compileOnly(libs.spotbugs) diff --git a/LavalinkServer/src/main/java/lavalink/server/Launcher.kt b/LavalinkServer/src/main/java/lavalink/server/Launcher.kt index 3778049d9..b7ccf9250 100644 --- a/LavalinkServer/src/main/java/lavalink/server/Launcher.kt +++ b/LavalinkServer/src/main/java/lavalink/server/Launcher.kt @@ -37,8 +37,7 @@ import org.springframework.boot.context.event.ApplicationFailedEvent import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.ApplicationListener import org.springframework.context.ConfigurableApplicationContext -import org.springframework.context.annotation.ComponentScan -import org.springframework.context.annotation.FilterType +import org.springframework.context.annotation.* import org.springframework.core.io.DefaultResourceLoader import java.io.File import java.time.Instant @@ -46,7 +45,8 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.* -@Suppress("SpringBootApplicationSetup", "SpringComponentScan") + +@Suppress("SpringComponentScan") @SpringBootApplication @ComponentScan( value = ["\${componentScan}"], @@ -68,29 +68,29 @@ object Launcher { .withZone(ZoneId.of("UTC")) val buildTime = dtf.format(Instant.ofEpochMilli(appInfo.buildTime)) val commitTime = dtf.format(Instant.ofEpochMilli(gitRepoState.commitTime * 1000)) - - val version = appInfo.version.takeUnless { it.startsWith("@") } ?: "Unknown" + val version = appInfo.versionBuild.takeUnless { it.startsWith("@") } + ?: "Unknown" return buildString { if (vanity) { - appendln() - appendln() - appendln(getVanity()) + appendLine() + appendLine() + appendLine(getVanity()) } if (!gitRepoState.isLoaded) { - appendln() - appendln("$indentation*** Unable to find or load Git metadata ***") + appendLine() + appendLine("$indentation*** Unable to find or load Git metadata ***") } - appendln() - append("${indentation}Version: "); appendln(version) + appendLine() + append("${indentation}Version: "); appendLine(version) if (gitRepoState.isLoaded) { - append("${indentation}Build time: "); appendln(buildTime) - append("${indentation}Branch "); appendln(gitRepoState.branch) - append("${indentation}Commit: "); appendln(gitRepoState.commitIdAbbrev) - append("${indentation}Commit time: "); appendln(commitTime) + append("${indentation}Build time: "); appendLine(buildTime) + append("${indentation}Branch "); appendLine(gitRepoState.branch) + append("${indentation}Commit: "); appendLine(gitRepoState.commitIdAbbrev) + append("${indentation}Commit time: "); appendLine(commitTime) } - append("${indentation}JVM: "); appendln(System.getProperty("java.version")) - append("${indentation}Lavaplayer "); appendln(PlayerLibrary.VERSION) + append("${indentation}JVM: "); appendLine(System.getProperty("java.version")) + append("${indentation}Lavaplayer "); appendLine(PlayerLibrary.VERSION) } } @@ -163,12 +163,18 @@ object Launcher { .resourceLoader(DefaultResourceLoader(pluginManager.classLoader)) .listeners( ApplicationListener { event: Any -> - if (event is ApplicationEnvironmentPreparedEvent) { - log.info(getVersionInfo()) - } else if (event is ApplicationReadyEvent) { - log.info("Lavalink is ready to accept connections.") - } else if (event is ApplicationFailedEvent) { - log.error("Application failed", event.exception) + when (event) { + is ApplicationEnvironmentPreparedEvent -> { + log.info(getVersionInfo()) + } + + is ApplicationReadyEvent -> { + log.info("Lavalink is ready to accept connections.") + } + + is ApplicationFailedEvent -> { + log.error("Application failed", event.exception) + } } } ).parent(parent) diff --git a/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginManager.kt b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginManager.kt index c0db2b89d..a1cd9bfb9 100644 --- a/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginManager.kt +++ b/LavalinkServer/src/main/java/lavalink/server/bootstrap/PluginManager.kt @@ -55,7 +55,7 @@ class PluginManager(config: PluginsConfig) { Declaration(fragments[0], fragments[1], fragments[2], repository) } - declarations.forEach declarationLoop@ { declaration -> + declarations.forEach declarationLoop@{ declaration -> pluginJars.forEach { jar -> if (declaration.name == jar.name) { if (declaration.version == jar.version) { @@ -102,7 +102,10 @@ class PluginManager(config: PluginsConfig) { if (jarsToLoad.isEmpty()) return emptyList() - val cl = URLClassLoader.newInstance(jarsToLoad.map { URL("jar:file:${it.absolutePath}!/") }.toTypedArray(), javaClass.classLoader) + val cl = URLClassLoader.newInstance( + jarsToLoad.map { URL("jar:file:${it.absolutePath}!/") }.toTypedArray(), + javaClass.classLoader + ) classLoader = cl val manifests = mutableListOf() diff --git a/LavalinkServer/src/main/java/lavalink/server/config/AudioPlayerConfiguration.kt b/LavalinkServer/src/main/java/lavalink/server/config/AudioPlayerConfiguration.kt index f6c6124a9..36aaaae69 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/AudioPlayerConfiguration.kt +++ b/LavalinkServer/src/main/java/lavalink/server/config/AudioPlayerConfiguration.kt @@ -3,7 +3,6 @@ package lavalink.server.config import com.sedmelluq.discord.lavaplayer.container.MediaContainerProbe import com.sedmelluq.discord.lavaplayer.container.MediaContainerRegistry import com.sedmelluq.discord.lavaplayer.player.AudioConfiguration -import com.sedmelluq.discord.lavaplayer.player.AudioConfiguration.ResamplingQuality import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager import com.sedmelluq.discord.lavaplayer.source.AudioSourceManager @@ -15,7 +14,6 @@ import com.sedmelluq.discord.lavaplayer.source.soundcloud.* import com.sedmelluq.discord.lavaplayer.source.twitch.TwitchStreamAudioSourceManager import com.sedmelluq.discord.lavaplayer.source.vimeo.VimeoAudioSourceManager import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioSourceManager -import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeHttpContextFilter import com.sedmelluq.lava.extensions.youtuberotator.YoutubeIpRotatorSetup import com.sedmelluq.lava.extensions.youtuberotator.planner.* import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv4Block @@ -30,6 +28,7 @@ import org.slf4j.LoggerFactory import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import java.net.InetAddress +import java.util.* import java.util.concurrent.TimeUnit import java.util.function.Predicate @@ -59,11 +58,11 @@ class AudioPlayerConfiguration { val defaultFrameBufferDuration = audioPlayerManager.frameBufferDuration serverConfig.frameBufferDurationMs?.let { if (it < 200) { // At the time of writing, LP enforces a minimum of 200ms. - log.warn("Buffer size of {}ms is illegal. Defaulting to {}", it, defaultFrameBufferDuration) + log.warn("Buffer size of ${it}ms is illegal. Defaulting to ${defaultFrameBufferDuration}ms") } val bufferDuration = it.takeIf { it >= 200 } ?: defaultFrameBufferDuration - log.debug("Setting frame buffer duration to {}", bufferDuration) + log.debug("Setting frame buffer duration to ${bufferDuration}ms") audioPlayerManager.frameBufferDuration = bufferDuration } @@ -71,16 +70,16 @@ class AudioPlayerConfiguration { audioPlayerManager.configuration.let { serverConfig.opusEncodingQuality?.let { opusQuality -> if (opusQuality !in 0..10) { - log.warn("Opus encoding quality {} is not within the range of 0 to 10. Defaulting to {}", opusQuality, defaultOpusEncodingQuality) + log.warn("Opus encoding quality $opusQuality is not within the range of 0 to 10. Defaulting to $defaultOpusEncodingQuality") } val qualitySetting = opusQuality.takeIf { it in 0..10 } ?: defaultOpusEncodingQuality - log.debug("Setting opusEncodingQuality to {}", qualitySetting) + log.debug("Setting opusEncodingQuality to $qualitySetting") it.opusEncodingQuality = qualitySetting } serverConfig.resamplingQuality?.let { resamplingQuality -> - log.debug("Setting resamplingQuality to {}", resamplingQuality) + log.debug("Setting resamplingQuality to $resamplingQuality") it.resamplingQuality = resamplingQuality } } @@ -88,16 +87,16 @@ class AudioPlayerConfiguration { val defaultTrackStuckThresholdMs = TimeUnit.NANOSECONDS.toMillis(audioPlayerManager.trackStuckThresholdNanos) serverConfig.trackStuckThresholdMs?.let { if (it < 100) { - log.warn("Track Stuck Threshold of {}ms is too small. Defaulting to {}ms", it, defaultTrackStuckThresholdMs) + log.warn("Track Stuck Threshold of ${it}ms is too small. Defaulting to ${defaultTrackStuckThresholdMs}ms") } val trackStuckThresholdMs: Long = it.takeIf { it >= 100 } ?: defaultTrackStuckThresholdMs - log.debug("Setting Track Stuck Threshold to {}ms", trackStuckThresholdMs) + log.debug("Setting Track Stuck Threshold to ${trackStuckThresholdMs}ms") audioPlayerManager.setTrackStuckThreshold(trackStuckThresholdMs) } serverConfig.useSeekGhosting?.let { seekGhosting -> - log.debug("Setting useSeekGhosting to {}", seekGhosting) + log.debug("Setting useSeekGhosting to $seekGhosting") audioPlayerManager.setUseSeekGhosting(seekGhosting) } @@ -129,6 +128,7 @@ class AudioPlayerConfiguration { retryLimit < 0 -> YoutubeIpRotatorSetup(routePlanner).forSource(youtube).setup() retryLimit == 0 -> YoutubeIpRotatorSetup(routePlanner).forSource(youtube) .withRetryLimit(Int.MAX_VALUE).setup() + else -> YoutubeIpRotatorSetup(routePlanner).forSource(youtube).withRetryLimit(retryLimit).setup() } @@ -161,7 +161,7 @@ class AudioPlayerConfiguration { audioSourceManagers.forEach { audioPlayerManager.registerSourceManager(it) - log.info("Registered {} provided from a plugin", it) + log.info("Registered $it provided from a plugin") } audioPlayerManager.configuration.isFilterHotSwapEnabled = true @@ -224,7 +224,7 @@ class AudioPlayerConfiguration { } } - return when (rateLimitConfig.strategy.toLowerCase().trim()) { + return when (rateLimitConfig.strategy.lowercase(Locale.getDefault()).trim()) { "rotateonban" -> RotatingIpRoutePlanner(ipBlocks, filter, rateLimitConfig.searchTriggersFail) "loadbalance" -> BalancingIpRoutePlanner(ipBlocks, filter, rateLimitConfig.searchTriggersFail) "nanoswitch" -> NanoIpRoutePlanner(ipBlocks, rateLimitConfig.searchTriggersFail) diff --git a/LavalinkServer/src/main/java/lavalink/server/config/AudioSourcesConfig.java b/LavalinkServer/src/main/java/lavalink/server/config/AudioSourcesConfig.java deleted file mode 100644 index cb28c0761..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/config/AudioSourcesConfig.java +++ /dev/null @@ -1,85 +0,0 @@ -package lavalink.server.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -/** - * Created by napster on 05.03.18. - */ -@ConfigurationProperties(prefix = "lavalink.server.sources") -@Component -public class AudioSourcesConfig { - - private boolean youtube = true; - private boolean bandcamp = true; - private boolean soundcloud = true; - private boolean twitch = true; - private boolean vimeo = true; - private boolean mixer = true; - private boolean http = true; - private boolean local = false; - - public boolean isYoutube() { - return youtube; - } - - public void setYoutube(boolean youtube) { - this.youtube = youtube; - } - - public boolean isBandcamp() { - return bandcamp; - } - - public void setBandcamp(boolean bandcamp) { - this.bandcamp = bandcamp; - } - - public boolean isSoundcloud() { - return soundcloud; - } - - public void setSoundcloud(boolean soundcloud) { - this.soundcloud = soundcloud; - } - - public boolean isTwitch() { - return twitch; - } - - public void setTwitch(boolean twitch) { - this.twitch = twitch; - } - - public boolean isVimeo() { - return vimeo; - } - - public void setVimeo(boolean vimeo) { - this.vimeo = vimeo; - } - - public boolean isMixer() { - return mixer; - } - - public void setMixer(boolean mixer) { - this.mixer = mixer; - } - - public boolean isHttp() { - return http; - } - - public void setHttp(boolean http) { - this.http = http; - } - - public boolean isLocal() { - return local; - } - - public void setLocal(boolean local) { - this.local = local; - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/config/AudioSourcesConfig.kt b/LavalinkServer/src/main/java/lavalink/server/config/AudioSourcesConfig.kt new file mode 100644 index 000000000..2a0a7dc4b --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/config/AudioSourcesConfig.kt @@ -0,0 +1,20 @@ +package lavalink.server.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +/** + * Created by napster on 05.03.18. + */ +@ConfigurationProperties(prefix = "lavalink.server.sources") +@Component +data class AudioSourcesConfig( + var isYoutube: Boolean = true, + var isBandcamp: Boolean = true, + var isSoundcloud: Boolean = true, + var isTwitch: Boolean = true, + var isVimeo: Boolean = true, + var isMixer: Boolean = true, + var isHttp: Boolean = true, + var isLocal: Boolean = false, +) diff --git a/LavalinkServer/src/main/java/lavalink/server/config/HttpConfig.kt b/LavalinkServer/src/main/java/lavalink/server/config/HttpConfig.kt index f5b56ece7..6da91dd0c 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/HttpConfig.kt +++ b/LavalinkServer/src/main/java/lavalink/server/config/HttpConfig.kt @@ -1,8 +1,8 @@ package lavalink.server.config data class HttpConfig( - var proxyHost: String = "", - var proxyPort: Int = 3128, - var proxyUser: String = "", - var proxyPassword: String = "" + var proxyHost: String = "", + var proxyPort: Int = 3128, + var proxyUser: String = "", + var proxyPassword: String = "" ) \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/config/KoeConfiguration.kt b/LavalinkServer/src/main/java/lavalink/server/config/KoeConfiguration.kt index 2575151ee..b7f5048e8 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/KoeConfiguration.kt +++ b/LavalinkServer/src/main/java/lavalink/server/config/KoeConfiguration.kt @@ -47,18 +47,24 @@ class KoeConfiguration(val serverConfig: ServerConfig) { if (nasSupported) { log.info("Enabling JDA-NAS") if (bufferSize < 40) { - log.warn("Buffer size of {}ms is illegal. Defaulting to {}", - bufferSize, UdpQueueFramePollerFactory.DEFAULT_BUFFER_DURATION) + log.warn("Buffer size of ${bufferSize}ms is illegal. Defaulting to ${UdpQueueFramePollerFactory.DEFAULT_BUFFER_DURATION}ms") bufferSize = UdpQueueFramePollerFactory.DEFAULT_BUFFER_DURATION } try { - setFramePollerFactory(UdpQueueFramePollerFactory(bufferSize, Runtime.getRuntime().availableProcessors())) + setFramePollerFactory( + UdpQueueFramePollerFactory( + bufferSize, + Runtime.getRuntime().availableProcessors() + ) + ) } catch (e: Throwable) { log.warn("Failed to enable JDA-NAS! GC pauses may cause your bot to stutter during playback.", e) } } else { - log.warn("This system and architecture appears to not support native audio sending! " - + "GC pauses may cause your bot to stutter during playback.") + log.warn( + "This system and architecture appears to not support native audio sending! " + + "GC pauses may cause your bot to stutter during playback." + ) } }.create() } \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/config/MetricsPrometheusConfigProperties.java b/LavalinkServer/src/main/java/lavalink/server/config/MetricsPrometheusConfigProperties.java deleted file mode 100644 index 2e8707963..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/config/MetricsPrometheusConfigProperties.java +++ /dev/null @@ -1,31 +0,0 @@ -package lavalink.server.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -/** - * Created by napster on 20.05.18. - */ -@Component -@ConfigurationProperties("metrics.prometheus") -public class MetricsPrometheusConfigProperties { - - private boolean enabled = false; - private String endpoint = ""; - - public boolean isEnabled() { - return enabled; - } - - public void setEnabled(boolean enabled) { - this.enabled = enabled; - } - - public String getEndpoint() { - return endpoint; - } - - public void setEndpoint(String endpoint) { - this.endpoint = endpoint; - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/config/MetricsPrometheusConfigProperties.kt b/LavalinkServer/src/main/java/lavalink/server/config/MetricsPrometheusConfigProperties.kt new file mode 100644 index 000000000..9b72e3dc3 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/config/MetricsPrometheusConfigProperties.kt @@ -0,0 +1,14 @@ +package lavalink.server.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +/** + * Created by napster on 20.05.18. + */ +@Component +@ConfigurationProperties("metrics.prometheus") +data class MetricsPrometheusConfigProperties( + var isEnabled: Boolean = false, + var endpoint: String = "" +) diff --git a/LavalinkServer/src/main/java/lavalink/server/config/RateLimitConfig.kt b/LavalinkServer/src/main/java/lavalink/server/config/RateLimitConfig.kt index e7a7acabf..cb5d42923 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/RateLimitConfig.kt +++ b/LavalinkServer/src/main/java/lavalink/server/config/RateLimitConfig.kt @@ -1,9 +1,9 @@ package lavalink.server.config data class RateLimitConfig( - var ipBlocks: List = emptyList(), - var excludedIps: List = emptyList(), - var strategy: String = "RotateOnBan", - var retryLimit: Int = -1, - var searchTriggersFail: Boolean = true + var ipBlocks: List = emptyList(), + var excludedIps: List = emptyList(), + var strategy: String = "RotateOnBan", + var retryLimit: Int = -1, + var searchTriggersFail: Boolean = true ) \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/config/RequestAuthorizationFilter.java b/LavalinkServer/src/main/java/lavalink/server/config/RequestAuthorizationFilter.java deleted file mode 100644 index 00187d8fe..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/config/RequestAuthorizationFilter.java +++ /dev/null @@ -1,58 +0,0 @@ -package lavalink.server.config; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpStatus; -import org.springframework.web.servlet.HandlerInterceptor; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; - -@Configuration -public class RequestAuthorizationFilter implements HandlerInterceptor, WebMvcConfigurer { - - private static final Logger log = LoggerFactory.getLogger(RequestAuthorizationFilter.class); - private ServerConfig serverConfig; - private MetricsPrometheusConfigProperties metricsConfig; - - public RequestAuthorizationFilter(ServerConfig serverConfig, MetricsPrometheusConfigProperties metricsConfig) { - this.serverConfig = serverConfig; - this.metricsConfig = metricsConfig; - } - - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { - // Collecting metrics is anonymous - if (!metricsConfig.getEndpoint().isEmpty() - && request.getServletPath().equals(metricsConfig.getEndpoint())) return true; - - if (request.getServletPath().equals("/error")) return true; - - String authorization = request.getHeader("Authorization"); - - if (authorization == null || !authorization.equals(serverConfig.getPassword())) { - String method = request.getMethod(); - String path = request.getRequestURI().substring(request.getContextPath().length()); - String ip = request.getRemoteAddr(); - - if (authorization == null) { - log.warn("Authorization missing for {} on {} {}", ip, method, path); - response.setStatus(HttpStatus.UNAUTHORIZED.value()); - return false; - } - log.warn("Authorization failed for {} on {} {}", ip, method, path); - response.setStatus(HttpStatus.FORBIDDEN.value()); - return false; - } - - return true; - } - - @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(this); - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/config/RequestAuthorizationFilter.kt b/LavalinkServer/src/main/java/lavalink/server/config/RequestAuthorizationFilter.kt new file mode 100644 index 000000000..de58f7a8f --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/config/RequestAuthorizationFilter.kt @@ -0,0 +1,51 @@ +package lavalink.server.config + +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpStatus +import org.springframework.web.servlet.HandlerInterceptor +import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +@Configuration +class RequestAuthorizationFilter( + private val serverConfig: ServerConfig, + private val metricsConfig: MetricsPrometheusConfigProperties +) : HandlerInterceptor, WebMvcConfigurer { + override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { + return when { + // Collecting metrics is anonymous + metricsConfig.endpoint.isNotEmpty() && request.servletPath == metricsConfig.endpoint -> true + + request.servletPath == "/error" -> true + + else -> { + val authorization = request.getHeader("Authorization") + if (authorization == null || authorization != serverConfig.password) { + val path = request.requestURI.substring(request.contextPath.length) + if (authorization == null) { + log.warn("Authorization missing for ${request.remoteAddr} on ${request.method} $path") + response.status = HttpStatus.UNAUTHORIZED.value() + return false + } + + log.warn("Authorization failed for ${request.remoteAddr} on ${request.method} $path") + response.status = HttpStatus.FORBIDDEN.value() + return false + } + + return true + } + } + } + + override fun addInterceptors(registry: InterceptorRegistry) { + registry.addInterceptor(this) + } + + companion object { + private val log = LoggerFactory.getLogger(RequestAuthorizationFilter::class.java) + } +} diff --git a/LavalinkServer/src/main/java/lavalink/server/config/RequestLoggingConfig.kt b/LavalinkServer/src/main/java/lavalink/server/config/RequestLoggingConfig.kt new file mode 100644 index 000000000..ac9e9b176 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/config/RequestLoggingConfig.kt @@ -0,0 +1,23 @@ +package lavalink.server.config + +import lavalink.server.io.RequestLoggingFilter +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +@ConfigurationProperties(prefix = "logging.request") +@ConditionalOnProperty("logging.request.enabled") +data class RequestLoggingConfig( + var includeClientInfo: Boolean = true, + var includeHeaders: Boolean = false, + var includeQueryString: Boolean = true, + var includePayload: Boolean = true, + var maxPayloadLength: Int = 10000, +) { + + @Bean + fun logFilter() = RequestLoggingFilter(this) + +} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/config/SentryConfigProperties.kt b/LavalinkServer/src/main/java/lavalink/server/config/SentryConfigProperties.kt index 2aec8e6ec..433884395 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/SentryConfigProperties.kt +++ b/LavalinkServer/src/main/java/lavalink/server/config/SentryConfigProperties.kt @@ -2,7 +2,6 @@ package lavalink.server.config import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.stereotype.Component -import java.util.* /** * Created by napster on 20.05.18. diff --git a/LavalinkServer/src/main/java/lavalink/server/config/SentryConfiguration.java b/LavalinkServer/src/main/java/lavalink/server/config/SentryConfiguration.java deleted file mode 100644 index e7c6e0f8f..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/config/SentryConfiguration.java +++ /dev/null @@ -1,107 +0,0 @@ -package lavalink.server.config; - -import ch.qos.logback.classic.Level; -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.classic.filter.ThresholdFilter; -import io.sentry.Sentry; -import io.sentry.SentryClient; -import io.sentry.logback.SentryAppender; -import lavalink.server.Launcher; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.context.annotation.Configuration; - -import java.io.IOException; -import java.util.Map; -import java.util.Properties; - -/** - * Created by napster on 25.04.18. - */ - -@Configuration -public class SentryConfiguration { - - private static final Logger log = LoggerFactory.getLogger(SentryConfiguration.class); - private static final String SENTRY_APPENDER_NAME = "SENTRY"; - - public SentryConfiguration(ServerConfig serverConfig, SentryConfigProperties sentryConfig) { - - String dsn = sentryConfig.getDsn(); - boolean warnDeprecatedDsnConfig = false; - if (dsn == null || dsn.isEmpty()) { - //try deprecated config location - //noinspection deprecation - dsn = serverConfig.getSentryDsn(); - warnDeprecatedDsnConfig = true; - } - - if (dsn != null && !dsn.isEmpty()) { - turnOn(dsn, sentryConfig.getTags(), sentryConfig.getEnvironment()); - if (warnDeprecatedDsnConfig) { - log.warn("Please update the location of the sentry dsn in lavalinks config file / your environment " - + "vars, it is now located under 'sentry.dsn' instead of 'lavalink.server.sentryDsn'."); - } - } else { - turnOff(); - } - } - - - public void turnOn(String dsn, Map tags, String environment) { - log.info("Turning on sentry"); - SentryClient sentryClient = Sentry.init(dsn); - if (!environment.isBlank()) sentryClient.setEnvironment(environment); - - if (tags != null) { - tags.forEach(sentryClient::addTag); - } - - // set the git commit hash this was build on as the release - Properties gitProps = new Properties(); - try { - //noinspection ConstantConditions - gitProps.load(Launcher.class.getClassLoader().getResourceAsStream("git.properties")); - } catch (NullPointerException | IOException e) { - log.error("Failed to load git repo information", e); - } - - String commitHash = gitProps.getProperty("git.commit.id"); - if (commitHash != null && !commitHash.isEmpty()) { - log.info("Setting sentry release to commit hash {}", commitHash); - sentryClient.setRelease(commitHash); - } else { - log.warn("No git commit hash found to set up sentry release"); - } - - getSentryLogbackAppender().start(); - } - - public void turnOff() { - log.warn("Turning off sentry"); - Sentry.close(); - getSentryLogbackAppender().stop(); - } - - //programmatically creates a sentry appender - private static synchronized SentryAppender getSentryLogbackAppender() { - LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); - ch.qos.logback.classic.Logger root = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME); - - SentryAppender sentryAppender = (SentryAppender) root.getAppender(SENTRY_APPENDER_NAME); - if (sentryAppender == null) { - sentryAppender = new SentryAppender(); - sentryAppender.setName(SENTRY_APPENDER_NAME); - - ThresholdFilter warningsOrAboveFilter = new ThresholdFilter(); - warningsOrAboveFilter.setLevel(Level.WARN.levelStr); - warningsOrAboveFilter.start(); - sentryAppender.addFilter(warningsOrAboveFilter); - - sentryAppender.setContext(loggerContext); - root.addAppender(sentryAppender); - } - return sentryAppender; - } - -} diff --git a/LavalinkServer/src/main/java/lavalink/server/config/SentryConfiguration.kt b/LavalinkServer/src/main/java/lavalink/server/config/SentryConfiguration.kt new file mode 100644 index 000000000..52ab13801 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/config/SentryConfiguration.kt @@ -0,0 +1,103 @@ +package lavalink.server.config + +import ch.qos.logback.classic.Level +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.filter.ThresholdFilter +import io.sentry.Sentry +import io.sentry.logback.SentryAppender +import lavalink.server.Launcher +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.context.annotation.Configuration +import java.io.IOException +import java.util.* + +/** + * Created by napster on 25.04.18. + */ +@Configuration +class SentryConfiguration(serverConfig: ServerConfig, sentryConfig: SentryConfigProperties) { + init { + var dsn = sentryConfig.dsn + var warnDeprecatedDsnConfig = false + if (dsn.isEmpty()) { + //try deprecated config location + dsn = serverConfig.sentryDsn + warnDeprecatedDsnConfig = true + } + + if (dsn.isNotEmpty()) { + turnOn(dsn, sentryConfig.tags, sentryConfig.environment) + if (warnDeprecatedDsnConfig) { + log.warn( + "Please update the location of the sentry dsn in lavalinks config file / your environment " + + "vars, it is now located under 'sentry.dsn' instead of 'lavalink.server.sentryDsn'." + ) + } + } else { + turnOff() + } + } + + private final fun turnOn(dsn: String, tags: Map, environment: String) { + log.info("Turning on sentry") + + val sentryClient = Sentry.init(dsn) + if (environment.isNotBlank()) sentryClient.environment = environment + + tags.forEach { (name, value) -> sentryClient.addTag(name, value) } + + // set the git commit hash this was build on as the release + val gitProps = Properties() + try { + gitProps.load(Launcher::class.java.classLoader.getResourceAsStream("git.properties")) + } catch (e: NullPointerException) { + log.error("Failed to load git repo information", e) + } catch (e: IOException) { + log.error("Failed to load git repo information", e) + } + + val commitHash = gitProps.getProperty("git.commit.id") + if (commitHash != null && commitHash.isNotEmpty()) { + log.info("Setting sentry release to commit hash $commitHash") + sentryClient.release = commitHash + } else { + log.warn("No git commit hash found to set up sentry release") + } + + sentryLogbackAppender.start() + } + + private final fun turnOff() { + log.warn("Turning off sentry") + Sentry.close() + sentryLogbackAppender.stop() + } + + companion object { + private val log = LoggerFactory.getLogger(SentryConfiguration::class.java) + private const val SENTRY_APPENDER_NAME = "SENTRY" + + @get:Synchronized + private val sentryLogbackAppender: SentryAppender + // programmatically creates a sentry appender + get() { + val loggerContext = LoggerFactory.getILoggerFactory() as LoggerContext + val root = loggerContext.getLogger(Logger.ROOT_LOGGER_NAME) + + var sentryAppender = root.getAppender(SENTRY_APPENDER_NAME) as? SentryAppender + if (sentryAppender == null) { + sentryAppender = SentryAppender() + sentryAppender.name = SENTRY_APPENDER_NAME + val warningsOrAboveFilter = ThresholdFilter() + warningsOrAboveFilter.setLevel(Level.WARN.levelStr) + warningsOrAboveFilter.start() + sentryAppender.addFilter(warningsOrAboveFilter) + sentryAppender.context = loggerContext + root.addAppender(sentryAppender) + } + + return sentryAppender + } + } +} diff --git a/LavalinkServer/src/main/java/lavalink/server/config/ServerConfig.kt b/LavalinkServer/src/main/java/lavalink/server/config/ServerConfig.kt index 810d6d1fc..04ab1c32a 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/ServerConfig.kt +++ b/LavalinkServer/src/main/java/lavalink/server/config/ServerConfig.kt @@ -30,6 +30,7 @@ import org.springframework.stereotype.Component @Component class ServerConfig { var password: String? = null + @get:Deprecated("use {@link SentryConfigProperties} instead.") var sentryDsn = "" var bufferDurationMs: Int? = null diff --git a/LavalinkServer/src/main/java/lavalink/server/config/WebConfiguration.kt b/LavalinkServer/src/main/java/lavalink/server/config/WebConfiguration.kt new file mode 100644 index 000000000..e17693263 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/config/WebConfiguration.kt @@ -0,0 +1,38 @@ +package lavalink.server.config + +import dev.arbjerg.lavalink.api.RestInterceptor +import dev.arbjerg.lavalink.protocol.v3.objectMapper +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.converter.HttpMessageConverter +import org.springframework.http.converter.StringHttpMessageConverter +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter +import org.springframework.web.servlet.config.annotation.EnableWebMvc +import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + + +@Configuration +@EnableWebMvc +class WebConfiguration(private val interceptors: List) : WebMvcConfigurer { + + override fun configureMessageConverters(converters: MutableList>) { + val builder = Jackson2ObjectMapperBuilder() + builder.configure(objectMapper()) + converters.add(StringHttpMessageConverter()) + converters.add(MappingJackson2HttpMessageConverter(builder.build())) + } + + @Bean + fun jackson2ObjectMapperBuilder(): Jackson2ObjectMapperBuilder { + val builder = Jackson2ObjectMapperBuilder() + builder.configure(objectMapper()) + return builder + } + + override fun addInterceptors(registry: InterceptorRegistry) { + interceptors.forEach { registry.addInterceptor(it) } + } + +} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.java b/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.java deleted file mode 100644 index 105de19fd..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.java +++ /dev/null @@ -1,29 +0,0 @@ -package lavalink.server.config; - -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; - -@Configuration -@EnableWebSocket -public class WebsocketConfig implements WebSocketConfigurer { - - private final SocketServer server; - private final HandshakeInterceptorImpl handshakeInterceptor; - - @Autowired - public WebsocketConfig(SocketServer server, HandshakeInterceptorImpl handshakeInterceptor) { - this.server = server; - this.handshakeInterceptor = handshakeInterceptor; - } - - @Override - public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(server, "/") - .addInterceptors(handshakeInterceptor); - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.kt b/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.kt new file mode 100644 index 000000000..e95b84d34 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/config/WebsocketConfig.kt @@ -0,0 +1,19 @@ +package lavalink.server.config + +import lavalink.server.io.HandshakeInterceptorImpl +import lavalink.server.io.SocketServer +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 + +@Configuration +@EnableWebSocket +class WebsocketConfig( + private val server: SocketServer, + private val handshakeInterceptor: HandshakeInterceptorImpl, +) : WebSocketConfigurer { + override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) { + registry.addHandler(server, "/", "/v3/websocket").addInterceptors(handshakeInterceptor) + } +} diff --git a/LavalinkServer/src/main/java/lavalink/server/config/YoutubeConfig.kt b/LavalinkServer/src/main/java/lavalink/server/config/YoutubeConfig.kt index 8462fb468..1c3139d7c 100644 --- a/LavalinkServer/src/main/java/lavalink/server/config/YoutubeConfig.kt +++ b/LavalinkServer/src/main/java/lavalink/server/config/YoutubeConfig.kt @@ -1,6 +1,6 @@ package lavalink.server.config data class YoutubeConfig( - var email: String = "", - var password: String = "" + var email: String = "", + var password: String = "" ) \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/info/AppInfo.java b/LavalinkServer/src/main/java/lavalink/server/info/AppInfo.java deleted file mode 100644 index b2898fd42..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/info/AppInfo.java +++ /dev/null @@ -1,63 +0,0 @@ -package lavalink.server.info; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Properties; - -/** - * Created by napster on 25.06.18. - *

- * Requires app.properties to be populated with values during the gradle build - */ -@Component -public class AppInfo { - - private static final Logger log = LoggerFactory.getLogger(AppInfo.class); - - private final String version; - private final String groupId; - private final String artifactId; - private final long buildTime; - - public AppInfo() { - InputStream resourceAsStream = this.getClass().getResourceAsStream("/app.properties"); - Properties prop = new Properties(); - try { - prop.load(resourceAsStream); - } catch (IOException e) { - log.error("Failed to load app.properties", e); - } - this.version = prop.getProperty("version"); - this.groupId = prop.getProperty("groupId"); - this.artifactId = prop.getProperty("artifactId"); - long bTime = -1L; - try { - bTime = Long.parseLong(prop.getProperty("buildTime")); - } catch (NumberFormatException ignored) { } - this.buildTime = bTime; - } - - public String getVersion() { - return this.version; - } - - public String getGroupId() { - return this.groupId; - } - - public String getArtifactId() { - return this.artifactId; - } - - public long getBuildTime() { - return this.buildTime; - } - - public String getVersionBuild() { - return this.version; - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/info/AppInfo.kt b/LavalinkServer/src/main/java/lavalink/server/info/AppInfo.kt new file mode 100644 index 000000000..21e746601 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/info/AppInfo.kt @@ -0,0 +1,38 @@ +package lavalink.server.info + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.io.IOException +import java.util.* + +/** + * Created by napster on 25.06.18. + * + * Requires app.properties to be populated with values during the gradle build + */ +@Component +class AppInfo { + companion object { + private val log = LoggerFactory.getLogger(AppInfo::class.java) + } + + final val versionBuild: String + final val groupId: String + final val artifactId: String + final val buildTime: Long + + init { + val resourceAsStream = this.javaClass.getResourceAsStream("/app.properties") + val prop = Properties() + try { + prop.load(resourceAsStream) + } catch (e: IOException) { + log.error("Failed to load app.properties", e) + } + + versionBuild = prop.getProperty("version") + groupId = prop.getProperty("groupId") + artifactId = prop.getProperty("artifactId") + buildTime = prop.getProperty("buildTime").toLongOrNull() ?: -1 + } +} diff --git a/LavalinkServer/src/main/java/lavalink/server/info/GitRepoState.java b/LavalinkServer/src/main/java/lavalink/server/info/GitRepoState.java deleted file mode 100644 index 600282436..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/info/GitRepoState.java +++ /dev/null @@ -1,103 +0,0 @@ -package lavalink.server.info; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Component; - -import java.io.IOException; -import java.time.OffsetDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Properties; - -/** - * Created by napster on 25.06.18. - *

- * Provides access to the values of the property file generated by whatever git info plugin we're using - *

- * Requires a generated git.properties, which can be achieved with the gradle git plugin - */ -@Component -public class GitRepoState { - - private static final Logger log = LoggerFactory.getLogger(GitRepoState.class); - - private boolean loaded = false; - private final String branch; - private final String commitId; - private final String commitIdAbbrev; - private final String commitUserName; - private final String commitUserEmail; - private final String commitMessageFull; - private final String commitMessageShort; - private final long commitTime; //epoch seconds - - @SuppressWarnings("ConstantConditions") - public GitRepoState() { - - Properties properties = new Properties(); - try { - properties.load(GitRepoState.class.getClassLoader().getResourceAsStream("git.properties")); - loaded = true; - } catch (NullPointerException e) { - log.trace("Failed to load git repo information. Did you build with the git gradle plugin? Is the git.properties file present?"); - } catch (IOException e) { - log.info("Failed to load git repo information due to suspicious IOException", e); - } - - this.branch = String.valueOf(properties.getOrDefault("git.branch", "")); - this.commitId = String.valueOf(properties.getOrDefault("git.commit.id", "")); - this.commitIdAbbrev = String.valueOf(properties.getOrDefault("git.commit.id.abbrev", "")); - this.commitUserName = String.valueOf(properties.getOrDefault("git.commit.user.name", "")); - this.commitUserEmail = String.valueOf(properties.getOrDefault("git.commit.user.email", "")); - 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 || time.equals("null")) { - this.commitTime = 0; - } else { - // https://github.com/n0mer/gradle-git-properties/issues/71 - DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ"); - this.commitTime = OffsetDateTime.from(dtf.parse(time)).toEpochSecond(); - } - } - - public String getBranch() { - return this.branch; - } - - public String getCommitId() { - return this.commitId; - } - - public String getCommitIdAbbrev() { - return this.commitIdAbbrev; - } - - public String getCommitUserName() { - return this.commitUserName; - } - - public String getCommitUserEmail() { - return this.commitUserEmail; - } - - public String getCommitMessageFull() { - return this.commitMessageFull; - } - - public String getCommitMessageShort() { - return this.commitMessageShort; - } - - /** - * @return commit time in epoch seconds - */ - public long getCommitTime() { - return this.commitTime; - } - - public boolean isLoaded() { - return loaded; - } -} - diff --git a/LavalinkServer/src/main/java/lavalink/server/info/GitRepoState.kt b/LavalinkServer/src/main/java/lavalink/server/info/GitRepoState.kt new file mode 100644 index 000000000..415e4f5a8 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/info/GitRepoState.kt @@ -0,0 +1,65 @@ +package lavalink.server.info + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.io.IOException +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import java.util.* + +/** + * Created by napster on 25.06.18. + * + * Provides access to the values of the property file generated by whatever git info plugin we're using* + * + * Requires a generated git.properties, which can be achieved with the gradle git plugin + */ +@Component +class GitRepoState { + companion object { + private val log = LoggerFactory.getLogger(GitRepoState::class.java) + } + + /** + * Commit time in epoch seconds + */ + final val commitTime: Long + final val branch: String + final val commitId: String + final val commitIdAbbrev: String + final val commitUserName: String + final val commitUserEmail: String + final val commitMessageFull: String + final val commitMessageShort: String + + final var isLoaded = false + + init { + val properties = Properties() + try { + properties.load(GitRepoState::class.java.classLoader.getResourceAsStream("git.properties")) + isLoaded = true + } catch (e: NullPointerException) { + log.trace("Failed to load git repo information. Did you build with the git gradle plugin? Is the git.properties file present?") + } catch (e: IOException) { + log.info("Failed to load git repo information due to suspicious IOException", e) + } + + branch = properties.getOrDefault("git.branch", "").toString() + commitId = properties.getOrDefault("git.commit.id", "").toString() + commitIdAbbrev = properties.getOrDefault("git.commit.id.abbrev", "").toString() + commitUserName = properties.getOrDefault("git.commit.user.name", "").toString() + commitUserEmail = properties.getOrDefault("git.commit.user.email", "").toString() + commitMessageFull = properties.getOrDefault("git.commit.message.full", "").toString() + commitMessageShort = properties.getOrDefault("git.commit.message.short", "").toString() + + val time = properties["git.commit.time"].toString() + commitTime = if (time == "null") { + 0 + } else { + // https://github.com/n0mer/gradle-git-properties/issues/71 + val dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ") + OffsetDateTime.from(dtf.parse(time)).toEpochSecond() + } + } +} diff --git a/LavalinkServer/src/main/java/lavalink/server/info/InfoRestHandler.java b/LavalinkServer/src/main/java/lavalink/server/info/InfoRestHandler.java deleted file mode 100644 index 8cd870707..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/info/InfoRestHandler.java +++ /dev/null @@ -1,22 +0,0 @@ -package lavalink.server.info; - -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * Created by napster on 08.03.19. - */ -@RestController -public class InfoRestHandler { - - private final AppInfo appInfo; - - public InfoRestHandler(AppInfo appInfo) { - this.appInfo = appInfo; - } - - @GetMapping("/version") - public String version() { - return appInfo.getVersionBuild(); - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/info/InfoRestHandler.kt b/LavalinkServer/src/main/java/lavalink/server/info/InfoRestHandler.kt new file mode 100644 index 000000000..00bbc9b6e --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/info/InfoRestHandler.kt @@ -0,0 +1,59 @@ +package lavalink.server.info + +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager +import com.sedmelluq.discord.lavaplayer.tools.PlayerLibrary +import dev.arbjerg.lavalink.api.AudioFilterExtension +import dev.arbjerg.lavalink.protocol.v3.* +import lavalink.server.bootstrap.PluginManager +import lavalink.server.config.ServerConfig +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +/** + * Created by napster on 08.03.19. + */ +@RestController +class InfoRestHandler( + appInfo: AppInfo, + gitRepoState: GitRepoState, + audioPlayerManager: AudioPlayerManager, + pluginManager: PluginManager, + serverConfig: ServerConfig, + filterExtensions: List +) { + + private val enabledFilers = (listOf( + "volume", + "equalizer", + "karaoke", + "timescale", + "tremolo", + "vibrato", + "distortion", + "rotation", + "channelMix", + "lowPass" + ) + filterExtensions.map { it.name }).filter { + it !in serverConfig.filters || serverConfig.filters[it] == true + } + + private val info = Info( + Version.fromSemver(appInfo.versionBuild), + appInfo.buildTime, + Git(gitRepoState.branch, gitRepoState.commitIdAbbrev, gitRepoState.commitTime * 1000), + System.getProperty("java.version"), + PlayerLibrary.VERSION, + audioPlayerManager.sourceManagers.map { it.sourceName }, + enabledFilers, + Plugins(pluginManager.pluginManifests.map { + Plugin(it.name, it.version) + }) + ) + private val version = appInfo.versionBuild + + @GetMapping("/v3/info") + fun info() = info + + @GetMapping("/version") + fun version() = version +} diff --git a/LavalinkServer/src/main/java/lavalink/server/io/EventEmitter.kt b/LavalinkServer/src/main/java/lavalink/server/io/EventEmitter.kt index 82a4285bc..522f48fea 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/EventEmitter.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/EventEmitter.kt @@ -16,10 +16,10 @@ class EventEmitter(private val context: SocketContext, private val listeners: Co fun onSocketContextDestroyed() = iterate { it.onSocketContextDestroyed(context) } fun onWebsocketMessageIn(message: String) = iterate { it.onWebsocketMessageIn(context, message) } fun onWebSocketMessageOut(message: String) = iterate { it.onWebSocketMessageOut(context, message) } - fun onNewPlayer(player: IPlayer) = iterate { it.onNewPlayer(context, player) } - fun onDestroyPlayer(player: IPlayer) = iterate { it.onDestroyPlayer(context, player) } + fun onNewPlayer(player: IPlayer) = iterate { it.onNewPlayer(context, player) } + fun onDestroyPlayer(player: IPlayer) = iterate { it.onDestroyPlayer(context, player) } - private fun iterate(func: (PluginEventHandler) -> Unit ) { + private fun iterate(func: (PluginEventHandler) -> Unit) { listeners.forEach { try { func(it) diff --git a/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.kt b/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.kt index b15dd5a43..805c1f196 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/HandshakeInterceptorImpl.kt @@ -23,27 +23,38 @@ constructor(private val serverConfig: ServerConfig, private val socketServer: So * * @return true if authenticated */ - override fun beforeHandshake(request: ServerHttpRequest, response: ServerHttpResponse, wsHandler: WebSocketHandler, - attributes: Map): Boolean { + @Suppress("UastIncorrectHttpHeaderInspection") + override fun beforeHandshake( + request: ServerHttpRequest, response: ServerHttpResponse, wsHandler: WebSocketHandler, + attributes: Map + ): Boolean { val password = request.headers.getFirst("Authorization") - val matches = password == serverConfig.password - if (matches) { - log.info("Incoming connection from " + request.remoteAddress) - } else { - log.error("Authentication failed from " + request.remoteAddress) + if (password != serverConfig.password) { + log.error("Authentication failed from ${request.remoteAddress}") response.setStatusCode(HttpStatus.UNAUTHORIZED) + return false } + if (request.headers.getFirst("User-Id") == null) { + log.error("Missing User-Id header from ${request.remoteAddress}") + response.setStatusCode(HttpStatus.BAD_REQUEST) + return false + } + + log.info("Incoming connection from ${request.remoteAddress}") + val resumeKey = request.headers.getFirst("Resume-Key") val resuming = resumeKey != null && socketServer.canResume(resumeKey) response.headers.add("Session-Resumed", resuming.toString()) - return matches + return true } // No action required - override fun afterHandshake(request: ServerHttpRequest, response: ServerHttpResponse, wsHandler: WebSocketHandler, - exception: Exception?) { + override fun afterHandshake( + request: ServerHttpRequest, response: ServerHttpResponse, wsHandler: WebSocketHandler, + exception: Exception? + ) { } } diff --git a/LavalinkServer/src/main/java/lavalink/server/io/PluginsEndpoint.kt b/LavalinkServer/src/main/java/lavalink/server/io/PluginsEndpoint.kt index f65ade11b..15de43a1a 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/PluginsEndpoint.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/PluginsEndpoint.kt @@ -1,5 +1,7 @@ package lavalink.server.io +import dev.arbjerg.lavalink.protocol.v3.Plugin +import dev.arbjerg.lavalink.protocol.v3.Plugins import lavalink.server.bootstrap.PluginManager import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RestController @@ -7,14 +9,9 @@ import org.springframework.web.bind.annotation.RestController @RestController class PluginsEndpoint(pluginManager: PluginManager) { - private val data = pluginManager.pluginManifests.map { - mutableMapOf().apply { - put("name", it.name) - put("version", it.version) - } - } + private val plugins = Plugins(pluginManager.pluginManifests.map { Plugin(it.name, it.version) }) @GetMapping("/plugins") - fun plugins() = data + fun plugins() = plugins -} \ No newline at end of file +} diff --git a/LavalinkServer/src/main/java/lavalink/server/io/RequestLoggingFilter.kt b/LavalinkServer/src/main/java/lavalink/server/io/RequestLoggingFilter.kt new file mode 100644 index 000000000..78ac57143 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/io/RequestLoggingFilter.kt @@ -0,0 +1,31 @@ +package lavalink.server.io + +import lavalink.server.config.RequestLoggingConfig +import org.slf4j.LoggerFactory +import org.springframework.web.filter.AbstractRequestLoggingFilter +import javax.servlet.http.HttpServletRequest + +class RequestLoggingFilter( + requestLoggingConfig: RequestLoggingConfig +) : AbstractRequestLoggingFilter() { + + companion object { + private val log = LoggerFactory.getLogger(RequestLoggingFilter::class.java) + } + + init { + isIncludeClientInfo = requestLoggingConfig.includeClientInfo + isIncludeHeaders = requestLoggingConfig.includeHeaders + isIncludeQueryString = requestLoggingConfig.includeQueryString + isIncludePayload = requestLoggingConfig.includePayload + maxPayloadLength = requestLoggingConfig.maxPayloadLength + setAfterMessagePrefix("") + setAfterMessageSuffix("") + } + + override fun beforeRequest(request: HttpServletRequest, message: String) {} + + override fun afterRequest(request: HttpServletRequest, message: String) { + log.info(message) + } +} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/io/ResponseHeaderFilter.java b/LavalinkServer/src/main/java/lavalink/server/io/ResponseHeaderFilter.java deleted file mode 100644 index 4fcb95e5a..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/io/ResponseHeaderFilter.java +++ /dev/null @@ -1,22 +0,0 @@ -package lavalink.server.io; - -import org.jetbrains.annotations.NotNull; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import javax.servlet.FilterChain; -import javax.servlet.ServletException; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import java.io.IOException; - -@Component -public class ResponseHeaderFilter extends OncePerRequestFilter { - - @Override - protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, - @NotNull FilterChain filterChain) throws IOException, ServletException { - response.addHeader("Lavalink-Api-Version", "3"); - filterChain.doFilter(request, response); - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/io/ResponseHeaderFilter.kt b/LavalinkServer/src/main/java/lavalink/server/io/ResponseHeaderFilter.kt new file mode 100644 index 000000000..d6cae7131 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/io/ResponseHeaderFilter.kt @@ -0,0 +1,19 @@ +package lavalink.server.io + +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import javax.servlet.FilterChain +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +@Component +class ResponseHeaderFilter : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + response.addHeader("Lavalink-Api-Version", "3") + filterChain.doFilter(request, response) + } +} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/io/RoutePlannerRestHandler.kt b/LavalinkServer/src/main/java/lavalink/server/io/RoutePlannerRestHandler.kt index aa3791082..e45362ff3 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/RoutePlannerRestHandler.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/RoutePlannerRestHandler.kt @@ -4,7 +4,7 @@ import com.sedmelluq.lava.extensions.youtuberotator.planner.AbstractRoutePlanner import com.sedmelluq.lava.extensions.youtuberotator.planner.NanoIpRoutePlanner import com.sedmelluq.lava.extensions.youtuberotator.planner.RotatingIpRoutePlanner import com.sedmelluq.lava.extensions.youtuberotator.planner.RotatingNanoIpRoutePlanner -import org.json.JSONObject +import dev.arbjerg.lavalink.protocol.v3.* import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping @@ -23,13 +23,13 @@ class RoutePlannerRestHandler(private val routePlanner: AbstractRoutePlanner?) { /** * Returns current information about the active AbstractRoutePlanner */ - @GetMapping("/routeplanner/status") + @GetMapping(value = ["/routeplanner/status", "/v3/routeplanner/status"]) fun getStatus(request: HttpServletRequest): ResponseEntity { val status = when (routePlanner) { null -> RoutePlannerStatus(null, null) else -> RoutePlannerStatus( - routePlanner.javaClass.simpleName, - getDetailBlock(routePlanner) + routePlanner.javaClass.simpleName, + getDetailBlock(routePlanner) ) } return ResponseEntity.ok(status) @@ -38,12 +38,14 @@ class RoutePlannerRestHandler(private val routePlanner: AbstractRoutePlanner?) { /** * Removes a single address from the addresses which are currently marked as failing */ - @PostMapping("/routeplanner/free/address") - fun freeSingleAddress(request: HttpServletRequest, @RequestBody requestBody: String): ResponseEntity { + @PostMapping(value = ["/routeplanner/free/address", "/v3/routeplanner/free/address"]) + fun freeSingleAddress( + request: HttpServletRequest, + @RequestBody body: RoutePlannerFreeAddress + ): ResponseEntity { routePlanner ?: throw RoutePlannerDisabledException() try { - val jsonObject = JSONObject(requestBody) - val address = InetAddress.getByName(jsonObject.getString("address")) + val address = InetAddress.getByName(body.address) routePlanner.freeAddress(address) return ResponseEntity.noContent().build() } catch (exception: UnknownHostException) { @@ -54,8 +56,8 @@ class RoutePlannerRestHandler(private val routePlanner: AbstractRoutePlanner?) { /** * Removes all addresses from the list which holds the addresses which are marked failing */ - @PostMapping("/routeplanner/free/all") - fun freeAllAddresses(request: HttpServletRequest): ResponseEntity { + @PostMapping(value = ["/routeplanner/free/all", "/v3/routeplanner/free/all"]) + fun freeAllAddresses(request: HttpServletRequest): ResponseEntity { routePlanner ?: throw RoutePlannerDisabledException() routePlanner.freeAllAddresses() return ResponseEntity.noContent().build() @@ -75,58 +77,31 @@ class RoutePlannerRestHandler(private val routePlanner: AbstractRoutePlanner?) { return when (planner) { is RotatingIpRoutePlanner -> RotatingIpRoutePlannerStatus( - ipBlockStatus, - failingAddressesStatus, - planner.rotateIndex.toString(), - planner.index.toString(), - planner.currentAddress.toString() + ipBlockStatus, + failingAddressesStatus, + planner.rotateIndex.toString(), + planner.index.toString(), + planner.currentAddress.toString() ) + is NanoIpRoutePlanner -> NanoIpRoutePlannerStatus( - ipBlockStatus, - failingAddressesStatus, - planner.currentAddress.toString() + ipBlockStatus, + failingAddressesStatus, + planner.currentAddress.toString() ) + is RotatingNanoIpRoutePlanner -> RotatingNanoIpRoutePlannerStatus( - ipBlockStatus, - failingAddressesStatus, - planner.currentBlock.toString(), - planner.addressIndexInBlock.toString() + ipBlockStatus, + failingAddressesStatus, + planner.currentBlock.toString(), + planner.addressIndexInBlock.toString() ) + else -> GenericRoutePlannerStatus(ipBlockStatus, failingAddressesStatus) } } - data class RoutePlannerStatus(val `class`: String?, val details: IRoutePlannerStatus?) - - interface IRoutePlannerStatus - data class GenericRoutePlannerStatus( - val ipBlock: IpBlockStatus, - val failingAddresses: List - ) : IRoutePlannerStatus - - data class RotatingIpRoutePlannerStatus( - val ipBlock: IpBlockStatus, - val failingAddresses: List, - val rotateIndex: String, - val ipIndex: String, - val currentAddress: String - ) : IRoutePlannerStatus - - data class NanoIpRoutePlannerStatus( - val ipBlock: IpBlockStatus, - val failingAddresses: List, - val currentAddressIndex: String - ) : IRoutePlannerStatus - - data class RotatingNanoIpRoutePlannerStatus( - val ipBlock: IpBlockStatus, - val failingAddresses: List, - val blockIndex: String, - val currentAddressIndex: String - ) : IRoutePlannerStatus - - data class FailingAddress(val failingAddress: String, val failingTimestamp: Long, val failingTime: String) - data class IpBlockStatus(val type: String, val size: String) + class RoutePlannerDisabledException : + ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Can't access disabled route planner") - class RoutePlannerDisabledException : ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Can't access disabled route planner") } \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/io/SessionRestHandler.kt b/LavalinkServer/src/main/java/lavalink/server/io/SessionRestHandler.kt new file mode 100644 index 000000000..10a31a459 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/io/SessionRestHandler.kt @@ -0,0 +1,34 @@ +package lavalink.server.io + +import dev.arbjerg.lavalink.protocol.v3.Session +import dev.arbjerg.lavalink.protocol.v3.SessionUpdate +import dev.arbjerg.lavalink.protocol.v3.takeIfPresent +import lavalink.server.util.socketContext +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + +@RestController +class SessionRestHandler(private val socketServer: SocketServer) { + + @PatchMapping("/v3/sessions/{sessionId}") + private fun patchSession( + @RequestBody sessionUpdate: SessionUpdate, + @PathVariable sessionId: String + ): ResponseEntity { + val context = socketContext(socketServer, sessionId) + + sessionUpdate.resumingKey.takeIfPresent { + context.resumeKey = it + } + + sessionUpdate.timeout.takeIfPresent { + context.resumeTimeout = it + } + + return ResponseEntity.ok(Session(context.resumeKey, context.resumeTimeout)) + } + +} diff --git a/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.kt b/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.kt index 28adbef9a..21281f7eb 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/SocketContext.kt @@ -22,17 +22,19 @@ package lavalink.server.io +import com.fasterxml.jackson.databind.ObjectMapper import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager import dev.arbjerg.lavalink.api.AudioFilterExtension import dev.arbjerg.lavalink.api.ISocketContext import dev.arbjerg.lavalink.api.PluginEventHandler import dev.arbjerg.lavalink.api.WebSocketExtension +import dev.arbjerg.lavalink.protocol.v3.Message 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 lavalink.server.config.ServerConfig -import lavalink.server.player.Player +import lavalink.server.player.LavalinkPlayer import moe.kyokobot.koe.KoeClient import moe.kyokobot.koe.KoeEventAdapter import moe.kyokobot.koe.MediaConnection @@ -43,36 +45,33 @@ import org.springframework.web.socket.WebSocketSession import org.springframework.web.socket.adapter.standard.StandardWebSocketSession import java.net.InetSocketAddress import java.util.* -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.Executors -import java.util.concurrent.ScheduledExecutorService -import java.util.concurrent.ScheduledFuture -import java.util.concurrent.TimeUnit +import java.util.concurrent.* class SocketContext( + private val sessionId: String, val audioPlayerManager: AudioPlayerManager, - val serverConfig: ServerConfig, + private val serverConfig: ServerConfig, private var session: WebSocketSession, private val socketServer: SocketServer, + statsCollector: StatsCollector, private val userId: String, private val clientName: String?, val koe: KoeClient, eventHandlers: Collection, webSocketExtensions: List, - filterExtensions: List - + filterExtensions: List, + private val objectMapper: ObjectMapper ) : ISocketContext { companion object { private val log = LoggerFactory.getLogger(SocketContext::class.java) } - //guildId <-> Player - private val players = ConcurrentHashMap() + //guildId <-> LavalinkPlayer + private val players = ConcurrentHashMap() val eventEmitter = EventEmitter(this, eventHandlers) - val wsHandler = WebSocketHandler(this, webSocketExtensions, filterExtensions, serverConfig.filters) + val wsHandler = WebSocketHandler(this, webSocketExtensions, filterExtensions, serverConfig, objectMapper) @Volatile var sessionPaused = false @@ -85,16 +84,16 @@ class SocketContext( private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() val playerUpdateService: ScheduledExecutorService - val playingPlayers: List + val playingPlayers: List get() { - val newList = LinkedList() + val newList = LinkedList() players.values.forEach { player -> if (player.isPlaying) newList.add(player) } return newList } init { - executor.scheduleAtFixedRate(StatsTask(this, socketServer), 0, 1, TimeUnit.MINUTES) + executor.scheduleAtFixedRate(statsCollector.createTask(this), 0, 1, TimeUnit.MINUTES) playerUpdateService = Executors.newScheduledThreadPool(2) { r -> val thread = Thread(r) @@ -104,7 +103,9 @@ class SocketContext( } } - fun getPlayer(guildId: String) = getPlayer(guildId.toLong()) + override fun getSessionId(): String { + return sessionId + } override fun getUserId(): Long { return userId.toLong() @@ -115,19 +116,19 @@ class SocketContext( } override fun getPlayer(guildId: Long) = players.computeIfAbsent(guildId) { - val player = Player(this, guildId, audioPlayerManager, serverConfig) + val player = LavalinkPlayer(this, guildId, serverConfig, audioPlayerManager) eventEmitter.onNewPlayer(player) player } - override fun getPlayers(): Map { + override fun getPlayers(): Map { return players.toMap() } /** * Gets or creates a media connection */ - fun getMediaConnection(player: Player): MediaConnection { + fun getMediaConnection(player: LavalinkPlayer): MediaConnection { val guildId = player.guildId var conn = koe.getConnection(guildId) if (conn == null) { @@ -158,7 +159,11 @@ class SocketContext( } override fun sendMessage(message: JSONObject) { - send(message) + send(message.toString()) + } + + override fun sendMessage(message: Any) { + send(objectMapper.writeValueAsString(message)) } override fun getState(): ISocketContext.State = when { @@ -170,8 +175,6 @@ class SocketContext( /** * Either sends the payload now or queues it up */ - fun send(payload: JSONObject) = send(payload.toString()) - private fun send(payload: String) { eventEmitter.onWebSocketMessageOut(payload) @@ -186,7 +189,7 @@ class SocketContext( WebSockets.sendText(payload, undertowSession.webSocketChannel, object : WebSocketCallback { override fun complete(channel: WebSocketChannel, context: Void?) { - log.trace("Sent {}", payload) + log.trace("Sent $payload") } override fun onError(channel: WebSocketChannel, context: Void?, throwable: Throwable) { @@ -214,7 +217,7 @@ class SocketContext( } internal fun shutdown() { - log.info("Shutting down " + playingPlayers.size + " playing players.") + log.info("Shutting down ${playingPlayers.size} playing players.") executor.shutdown() playerUpdateService.shutdown() players.values.forEach { @@ -236,18 +239,10 @@ class SocketContext( session.close() } - private inner class WsEventHandler(private val player: Player) : KoeEventAdapter() { + private inner class WsEventHandler(private val player: LavalinkPlayer) : KoeEventAdapter() { override fun gatewayClosed(code: Int, reason: String?, byRemote: Boolean) { - val out = JSONObject() - out.put("op", "event") - out.put("type", "WebSocketClosedEvent") - out.put("guildId", player.guildId.toString()) - out.put("reason", reason ?: "") - out.put("code", code) - out.put("byRemote", byRemote) - - send(out) - + val event = Message.WebSocketClosedEvent(code, reason ?: "", byRemote, player.guildId.toString()) + sendMessage(event) SocketServer.sendPlayerUpdate(this@SocketContext, player) } diff --git a/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt b/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt index 1f7a51ddc..c3225bcfa 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/SocketServer.kt @@ -22,12 +22,15 @@ package lavalink.server.io +import com.fasterxml.jackson.databind.ObjectMapper import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager import dev.arbjerg.lavalink.api.AudioFilterExtension import dev.arbjerg.lavalink.api.PluginEventHandler import dev.arbjerg.lavalink.api.WebSocketExtension +import dev.arbjerg.lavalink.protocol.v3.Message +import dev.arbjerg.lavalink.protocol.v3.PlayerState import lavalink.server.config.ServerConfig -import lavalink.server.player.Player +import lavalink.server.player.LavalinkPlayer import moe.kyokobot.koe.Koe import moe.kyokobot.koe.KoeOptions import org.json.JSONObject @@ -41,37 +44,51 @@ import java.util.concurrent.ConcurrentHashMap @Service class SocketServer( - private val serverConfig: ServerConfig, - private val audioPlayerManager: AudioPlayerManager, - koeOptions: KoeOptions, - private val eventHandlers: List, - private val webSocketExtensions: List, - private val filterExtensions: List + private val serverConfig: ServerConfig, + val audioPlayerManager: AudioPlayerManager, + koeOptions: KoeOptions, + private val eventHandlers: List, + private val webSocketExtensions: List, + private val filterExtensions: List, + private val objectMapper: ObjectMapper ) : TextWebSocketHandler() { - // userId <-> shardCount + // sessionID <-> Session val contextMap = ConcurrentHashMap() private val resumableSessions = mutableMapOf() private val koe = Koe.koe(koeOptions) + private val statsCollector = StatsCollector(this) + private val charPool = ('a'..'z') + ('0'..'9') companion object { private val log = LoggerFactory.getLogger(SocketServer::class.java) - fun sendPlayerUpdate(socketContext: SocketContext, player: Player) { - val json = JSONObject() + fun sendPlayerUpdate(socketContext: SocketContext, player: LavalinkPlayer) { + if (socketContext.sessionPaused) return - val state = player.state val connection = socketContext.getMediaConnection(player).gatewayConnection - state.put("connected", connection?.isOpen == true) - state.put("ping", connection?.ping ?: -1) - - json.put("op", "playerUpdate") - json.put("guildId", player.guildId.toString()) - json.put("state", state) - socketContext.send(json) + socketContext.sendMessage( + Message.PlayerUpdateEvent( + PlayerState( + System.currentTimeMillis(), + player.audioPlayer.playingTrack?.position ?: 0, + connection?.isOpen == true, + connection?.ping ?: -1L + ), + player.guildId.toString() + ) + ) } } + private fun generateUniqueSessionId(): String { + var sessionId: String + do { + sessionId = List(16) { charPool.random() }.joinToString("") + } while (contextMap[sessionId] != null) + return sessionId + } + val contexts: Collection get() = contextMap.values @@ -86,27 +103,35 @@ class SocketServer( if (resumeKey != null) resumable = resumableSessions.remove(resumeKey) if (resumable != null) { - contextMap[session.id] = resumable + contextMap[resumable.sessionId] = resumable resumable.resume(session) log.info("Resumed session with key $resumeKey") resumable.eventEmitter.onWebSocketOpen(true) + resumable.sendMessage(Message.ReadyEvent(true, resumable.sessionId)) return } + val sessionId = generateUniqueSessionId() + session.attributes["sessionId"] = sessionId + val socketContext = SocketContext( - audioPlayerManager, - serverConfig, - session, - this, - userId, - clientName, - koe.newClient(userId.toLong()), - eventHandlers, - webSocketExtensions, - filterExtensions + sessionId, + audioPlayerManager, + serverConfig, + session, + this, + statsCollector, + userId, + clientName, + koe.newClient(userId.toLong()), + eventHandlers, + webSocketExtensions, + filterExtensions, + objectMapper ) - contextMap[session.id] = socketContext + contextMap[sessionId] = socketContext socketContext.eventEmitter.onWebSocketOpen(false) + socketContext.sendMessage(Message.ReadyEvent(false, sessionId)) if (clientName != null) { log.info("Connection successfully established from $clientName") @@ -121,52 +146,42 @@ class SocketServer( } } - override fun afterConnectionClosed(session: WebSocketSession?, status: CloseStatus?) { - val context = contextMap.remove(session!!.id) ?: return + override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) { + val context = contextMap.remove(session.id) ?: return if (context.resumeKey != null) { resumableSessions.remove(context.resumeKey!!)?.let { removed -> - log.warn("Shutdown resumable session with key ${removed.resumeKey} because it has the same key as a " + - "newly disconnected resumable session.") + log.warn( + "Shutdown resumable session with key ${removed.resumeKey} because it has the same key as a " + + "newly disconnected resumable session." + ) removed.shutdown() } resumableSessions[context.resumeKey!!] = context context.pause() - log.info("Connection closed from {} with status {} -- " + - "Session can be resumed within the next {} seconds with key {}", - session.remoteAddress, - status, - context.resumeTimeout, - context.resumeKey + log.info( + "Connection closed from ${session.remoteAddress} with status $status -- " + + "Session can be resumed within the next ${context.resumeTimeout} seconds with key ${context.resumeKey}", ) return } - log.info("Connection closed from {} -- {}", session.remoteAddress, status) + 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) { + override fun handleTextMessage(session: WebSocketSession, message: TextMessage) { val json = JSONObject(message.payload) log.info(message.payload) if (!session.isOpen) { - log.error("Ignoring closing websocket: " + session.remoteAddress!!) + log.error("Ignoring closing websocket: ${session.remoteAddress!!}") return } - val context = contextMap[session.id] - ?: throw IllegalStateException("No context for session ID ${session.id}. Broken websocket?") + val context = contextMap[session.attributes["sessionId"]] + ?: throw IllegalStateException("No context for session ID ${session.id}. Broken websocket?") context.eventEmitter.onWebsocketMessageIn(message.payload) context.wsHandler.handle(json) } diff --git a/LavalinkServer/src/main/java/lavalink/server/io/StatsCollector.kt b/LavalinkServer/src/main/java/lavalink/server/io/StatsCollector.kt new file mode 100644 index 000000000..d70f1fc56 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/io/StatsCollector.kt @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2021 Freya Arbjerg and contributors + * + * 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 dev.arbjerg.lavalink.protocol.v3.* +import lavalink.server.Launcher +import lavalink.server.player.AudioLossCounter +import org.slf4j.LoggerFactory +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController +import oshi.SystemInfo +import kotlin.Exception + +@RestController +class StatsCollector(val socketServer: SocketServer) { + companion object { + private val log = LoggerFactory.getLogger(StatsCollector::class.java) + + private val si = SystemInfo() + private val hal get() = si.hardware + private val os get() = si.operatingSystem + + private var prevTicks: LongArray? = null + } + + private var uptime = 0.0 + private var cpuTime = 0.0 + + // Record for next invocation + private val processRecentCpuUsage: Double + get() { + val p = os.getProcess(os.processId) + + val output: Double = if (cpuTime != 0.0) { + val uptimeDiff = p.upTime - uptime + val cpuDiff = p.kernelTime + p.userTime - cpuTime + cpuDiff / uptimeDiff + } else { + (p.kernelTime + p.userTime).toDouble() / p.userTime.toDouble() + } + + // Record for next invocation + uptime = p.upTime.toDouble() + cpuTime = (p.kernelTime + p.userTime).toDouble() + return output / hal.processor.logicalProcessorCount + } + + fun createTask(context: SocketContext): Runnable = Runnable { + try { + val stats = retrieveStats(context) + context.sendMessage(Message.StatsEvent(stats)) + } catch (e: Exception) { + log.error("Exception while sending stats", e) + } + } + + @GetMapping("/v3/stats") + fun getStats() = retrieveStats() + + fun retrieveStats(context: SocketContext? = null): Stats { + val playersTotal = intArrayOf(0) + val playersPlaying = intArrayOf(0) + socketServer.contexts.forEach { socketContext -> + playersTotal[0] += socketContext.players.size + playersPlaying[0] += socketContext.playingPlayers.size + } + + val uptime = System.currentTimeMillis() - Launcher.startTime + + // In bytes + val runtime = Runtime.getRuntime() + val mem = Memory( + free = runtime.freeMemory(), + used = runtime.totalMemory() - runtime.freeMemory(), + allocated = runtime.totalMemory(), + reservable = runtime.maxMemory() + ) + + // prevTicks will be null so set it to a value. + if (prevTicks == null) { + prevTicks = hal.processor.systemCpuLoadTicks + } + + val cpu = Cpu( + runtime.availableProcessors(), + systemLoad = hal.processor.getSystemCpuLoadBetweenTicks(prevTicks), + lavalinkLoad = processRecentCpuUsage.takeIf { it.isFinite() } ?: 0.0 + ) + + // Set new prevTicks to current value for more accurate baseline, and checks in the next schedule. + prevTicks = hal.processor.systemCpuLoadTicks + + var frameStats: FrameStats? = null + if (context != null) { + var playerCount = 0 + var totalSent = 0 + var totalNulled = 0 + for (player in context.playingPlayers) { + val counter = player.audioLossCounter + if (!counter.isDataUsable) continue + playerCount++ + totalSent += counter.lastMinuteSuccess + totalNulled += counter.lastMinuteLoss + } + + // We can't divide by 0 + if (playerCount != 0) { + val totalDeficit = playerCount * + AudioLossCounter.EXPECTED_PACKET_COUNT_PER_MIN - + (totalSent + totalNulled) + + frameStats = FrameStats( + (totalSent / playerCount), + (totalNulled / playerCount), + (totalDeficit / playerCount) + ) + } + } + + return Stats( + frameStats, + playersTotal[0], + playersPlaying[0], + uptime, + mem, + cpu + ) + } +} diff --git a/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java b/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java deleted file mode 100644 index 0a75215e8..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/io/StatsTask.java +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright (c) 2021 Freya Arbjerg and contributors - * - * 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 lavalink.server.Launcher; -import lavalink.server.player.AudioLossCounter; -import lavalink.server.player.Player; -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import oshi.SystemInfo; -import oshi.hardware.HardwareAbstractionLayer; -import oshi.software.os.OSProcess; -import oshi.software.os.OperatingSystem; - -public class StatsTask implements Runnable { - - private static final Logger log = LoggerFactory.getLogger(StatsTask.class); - - private final SocketContext context; - private final SocketServer socketServer; - - private final SystemInfo si = new SystemInfo(); - private final HardwareAbstractionLayer hal = si.getHardware(); - /** CPU ticks used for calculations in CPU load. */ - private long[] prevTicks; - - StatsTask(SocketContext context, SocketServer socketServer) { - this.context = context; - this.socketServer = socketServer; - } - - @Override - public void run() { - try { - sendStats(); - } catch (Exception e) { - log.error("Exception while sending stats", e); - } - } - - private void sendStats() { - if (context.getSessionPaused()) return; - - JSONObject out = new JSONObject(); - - final int[] playersTotal = {0}; - final int[] playersPlaying = {0}; - - socketServer.getContexts().forEach(socketContext -> { - playersTotal[0] += socketContext.getPlayers().size(); - playersPlaying[0] += socketContext.getPlayingPlayers().size(); - }); - - out.put("op", "stats"); - out.put("players", playersTotal[0]); - out.put("playingPlayers", playersPlaying[0]); - out.put("uptime", System.currentTimeMillis() - Launcher.INSTANCE.getStartTime()); - - // In bytes - JSONObject mem = new JSONObject(); - mem.put("free", Runtime.getRuntime().freeMemory()); - mem.put("used", Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()); - mem.put("allocated", Runtime.getRuntime().totalMemory()); - mem.put("reservable", Runtime.getRuntime().maxMemory()); - out.put("memory", mem); - - - JSONObject cpu = new JSONObject(); - cpu.put("cores", Runtime.getRuntime().availableProcessors()); - // prevTicks will be null so set it to a value. - if(prevTicks == null) { - prevTicks = hal.getProcessor().getSystemCpuLoadTicks(); - } - // Compare current CPU ticks with previous to establish a CPU load and return double. - cpu.put("systemLoad", hal.getProcessor().getSystemCpuLoadBetweenTicks(prevTicks)); - // Set new prevTicks to current value for more accurate baseline, and checks in next schedule. - prevTicks = hal.getProcessor().getSystemCpuLoadTicks(); - double load = getProcessRecentCpuUsage(); - if (!Double.isFinite(load)) load = 0; - cpu.put("lavalinkLoad", load); - - out.put("cpu", cpu); - - int totalSent = 0; - int totalNulled = 0; - int players = 0; - - for (Player player : context.getPlayingPlayers()) { - AudioLossCounter counter = player.getAudioLossCounter(); - if (!counter.isDataUsable()) continue; - - players++; - totalSent += counter.getLastMinuteSuccess(); - totalNulled += counter.getLastMinuteLoss(); - } - - int totalDeficit = players * AudioLossCounter.EXPECTED_PACKET_COUNT_PER_MIN - - (totalSent + totalNulled); - - // We can't divide by 0 - if (players != 0) { - JSONObject frames = new JSONObject(); - frames.put("sent", totalSent / players); - frames.put("nulled", totalNulled / players); - frames.put("deficit", totalDeficit / players); - out.put("frameStats", frames); - } - - context.send(out); - } - - private double uptime = 0; - private double cpuTime = 0; - - private double getProcessRecentCpuUsage() { - double output; - HardwareAbstractionLayer hal = si.getHardware(); - OperatingSystem os = si.getOperatingSystem(); - OSProcess p = os.getProcess(os.getProcessId()); - - if (cpuTime != 0) { - double uptimeDiff = p.getUpTime() - uptime; - double cpuDiff = (p.getKernelTime() + p.getUserTime()) - cpuTime; - output = cpuDiff / uptimeDiff; - } else { - output = ((double) (p.getKernelTime() + p.getUserTime())) / (double) p.getUserTime(); - } - - // Record for next invocation - uptime = p.getUpTime(); - cpuTime = p.getKernelTime() + p.getUserTime(); - return output / hal.getProcessor().getLogicalProcessorCount(); - } - -} diff --git a/LavalinkServer/src/main/java/lavalink/server/io/WSCodes.java b/LavalinkServer/src/main/java/lavalink/server/io/WSCodes.java deleted file mode 100644 index a7cf5e18c..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/io/WSCodes.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (c) 2021 Freya Arbjerg and contributors - * - * 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; - -public class WSCodes { - - public static final int INTERNAL_ERROR = 4000; - public static final int AUTHORIZATION_REJECTED = 4001; - -} diff --git a/LavalinkServer/src/main/java/lavalink/server/io/WebSocketHandler.kt b/LavalinkServer/src/main/java/lavalink/server/io/WebSocketHandler.kt index 933f61467..e79db30ca 100644 --- a/LavalinkServer/src/main/java/lavalink/server/io/WebSocketHandler.kt +++ b/LavalinkServer/src/main/java/lavalink/server/io/WebSocketHandler.kt @@ -1,30 +1,38 @@ package lavalink.server.io +import com.fasterxml.jackson.databind.ObjectMapper import com.sedmelluq.discord.lavaplayer.track.TrackMarker import dev.arbjerg.lavalink.api.AudioFilterExtension import dev.arbjerg.lavalink.api.WebSocketExtension +import dev.arbjerg.lavalink.protocol.v3.Filters +import dev.arbjerg.lavalink.protocol.v3.decodeTrack +import lavalink.server.config.ServerConfig import lavalink.server.player.TrackEndMarkerHandler import lavalink.server.player.filters.Band +import lavalink.server.player.filters.EqualizerConfig import lavalink.server.player.filters.FilterChain -import lavalink.server.util.Util import moe.kyokobot.koe.VoiceServerInfo import org.json.JSONObject import org.slf4j.Logger import org.slf4j.LoggerFactory -import kotlin.reflect.KFunction1 class WebSocketHandler( private val context: SocketContext, - private val wsExtensions: List, + wsExtensions: List, private val filterExtensions: List, - private val filterConfig: Map + serverConfig: ServerConfig, + private val objectMapper: ObjectMapper ) { - companion object { private val log: Logger = LoggerFactory.getLogger(WebSocketHandler::class.java) + + fun WebSocketExtension.toHandler(ctx: SocketContext): Pair Unit> { + return opName to { onInvocation(ctx, it) } + } } private var loggedEqualizerDeprecationWarning = false + private var loggedWsCommandsDeprecationWarning = false private val handlers: Map Unit> = mutableMapOf( "voiceUpdate" to ::voiceUpdate, @@ -37,14 +45,15 @@ class WebSocketHandler( "filters" to ::filters, "destroy" to ::destroy, "configureResuming" to ::configureResuming - ).apply { - wsExtensions.forEach { - val func = fun(json: JSONObject) { it.onInvocation(context, json) } - this[it.opName] = func as KFunction1 - } - } + ) + wsExtensions.associate { it.toHandler(context) } + + private val disabledFilters = serverConfig.filters.entries.filter { !it.value }.map { it.key } fun handle(json: JSONObject) { + if (!loggedWsCommandsDeprecationWarning) { + log.warn("Sending websocket commands to Lavalink has been deprecated and will be removed in API version 4. API version 3 will be removed in Lavalink 5. Please use the new REST endpoints instead.") + loggedWsCommandsDeprecationWarning = true + } val op = json.getString("op") val handler = handlers[op] ?: return log.warn("Unknown op '$op'") handler(json) @@ -71,15 +80,15 @@ class WebSocketHandler( } private fun play(json: JSONObject) { - val player = context.getPlayer(json.getString("guildId")) + val player = context.getPlayer(json.getLong("guildId")) val noReplace = json.optBoolean("noReplace", false) - if (noReplace && player.playingTrack != null) { + if (noReplace && player.track != null) { log.info("Skipping play request because of noReplace") return } - val track = Util.toAudioTrack(context.audioPlayerManager, json.getString("track")) + val track = decodeTrack(context.audioPlayerManager, json.getString("track")) if (json.has("startTime")) { track.position = json.getLong("startTime") @@ -102,53 +111,62 @@ class WebSocketHandler( player.play(track) val conn = context.getMediaConnection(player) - context.getPlayer(json.getString("guildId")).provideTo(conn) + context.getPlayer(json.getLong("guildId")).provideTo(conn) } private fun stop(json: JSONObject) { - val player = context.getPlayer(json.getString("guildId")) + val player = context.getPlayer(json.getLong("guildId")) player.stop() } private fun pause(json: JSONObject) { - val player = context.getPlayer(json.getString("guildId")) + val player = context.getPlayer(json.getLong("guildId")) player.setPause(json.getBoolean("pause")) SocketServer.sendPlayerUpdate(context, player) } private fun seek(json: JSONObject) { - val player = context.getPlayer(json.getString("guildId")) + val player = context.getPlayer(json.getLong("guildId")) player.seekTo(json.getLong("position")) SocketServer.sendPlayerUpdate(context, player) } private fun volume(json: JSONObject) { - val player = context.getPlayer(json.getString("guildId")) + val player = context.getPlayer(json.getLong("guildId")) player.setVolume(json.getInt("volume")) } private fun equalizer(json: JSONObject) { - if (!loggedEqualizerDeprecationWarning) log.warn("The 'equalizer' op has been deprecated in favour of the " + - "'filters' op. Please switch to use that one, as this op will get removed in v4.") - loggedEqualizerDeprecationWarning = true + if (!loggedEqualizerDeprecationWarning) { + log.warn( + "The 'equalizer' op has been deprecated in favour of the " + + "'filters' op. Please switch to that one, as this op will be removed in API version 4." + ) - if (filterConfig["equalizer"] == false) return log.warn("Equalizer is disabled in the config, ignoring equalizer op") + loggedEqualizerDeprecationWarning = true + } + if ("equalizer" in disabledFilters) return log.warn("Equalizer filter is disabled in the config, ignoring equalizer op") - val player = context.getPlayer(json.getString("guildId")) + val player = context.getPlayer(json.getLong("guildId")) - val list = mutableListOf() - json.getJSONArray("bands").forEach { b -> - val band = b as JSONObject - list.add(Band(band.getInt("band"), band.getFloat("gain"))) - } - val filters = player.filters ?: FilterChain() - filters.equalizer = list + val bands = json.getJSONArray("bands") + .filterIsInstance() + .map { b -> Band(b.getInt("band"), b.getFloat("gain")) } + + val filters = player.filters + filters.setEqualizer(EqualizerConfig(bands)) player.filters = filters } private fun filters(json: JSONObject) { val player = context.getPlayer(json.getLong("guildId")) - player.filters = FilterChain.parse(json, filterExtensions, filterConfig) + val filters = objectMapper.readValue(json.toString(), Filters::class.java) + val invalidFilters = filters.validate(disabledFilters) + if (invalidFilters.isNotEmpty()) { + log.warn("The following filters are disabled in the config and are being ignored: $invalidFilters") + return + } + player.filters = FilterChain.parse(filters, filterExtensions) } private fun destroy(json: JSONObject) { diff --git a/LavalinkServer/src/main/java/lavalink/server/player/AudioLoader.java b/LavalinkServer/src/main/java/lavalink/server/player/AudioLoader.java deleted file mode 100644 index a32276633..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/player/AudioLoader.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright (c) 2021 Freya Arbjerg and contributors - * - * 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.player; - -import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; -import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; -import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; -import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.concurrent.atomic.AtomicBoolean; - -public class AudioLoader implements AudioLoadResultHandler { - - private static final Logger log = LoggerFactory.getLogger(AudioLoader.class); - private static final LoadResult NO_MATCHES = new LoadResult(ResultStatus.NO_MATCHES, Collections.emptyList(), - null, null); - - private final AudioPlayerManager audioPlayerManager; - - private final CompletableFuture loadResult = new CompletableFuture<>(); - private final AtomicBoolean used = new AtomicBoolean(false); - - public AudioLoader(AudioPlayerManager audioPlayerManager) { - this.audioPlayerManager = audioPlayerManager; - } - - public CompletionStage load(String identifier) { - boolean isUsed = this.used.getAndSet(true); - if (isUsed) { - throw new IllegalStateException("This loader can only be used once per instance"); - } - - log.trace("Loading item with identifier {}", identifier); - this.audioPlayerManager.loadItem(identifier, this); - - return loadResult; - } - - @Override - public void trackLoaded(AudioTrack audioTrack) { - log.info("Loaded track " + audioTrack.getInfo().title); - ArrayList result = new ArrayList<>(); - result.add(audioTrack); - this.loadResult.complete(new LoadResult(ResultStatus.TRACK_LOADED, result, null, null)); - } - - @Override - public void playlistLoaded(AudioPlaylist audioPlaylist) { - log.info("Loaded playlist " + audioPlaylist.getName()); - - String playlistName = null; - Integer selectedTrack = null; - if (!audioPlaylist.isSearchResult()) { - playlistName = audioPlaylist.getName(); - selectedTrack = audioPlaylist.getTracks().indexOf(audioPlaylist.getSelectedTrack()); - } - - ResultStatus status = audioPlaylist.isSearchResult() ? ResultStatus.SEARCH_RESULT : ResultStatus.PLAYLIST_LOADED; - List loadedItems = audioPlaylist.getTracks(); - - this.loadResult.complete(new LoadResult(status, loadedItems, playlistName, selectedTrack)); - } - - @Override - public void noMatches() { - log.info("No matches found"); - this.loadResult.complete(NO_MATCHES); - } - - @Override - public void loadFailed(FriendlyException e) { - log.error("Load failed", e); - this.loadResult.complete(new LoadResult(e)); - } - -} diff --git a/LavalinkServer/src/main/java/lavalink/server/player/AudioLoader.kt b/LavalinkServer/src/main/java/lavalink/server/player/AudioLoader.kt new file mode 100644 index 000000000..bb8bd976d --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/player/AudioLoader.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2021 Freya Arbjerg and contributors + * + * 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.player + +import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager +import com.sedmelluq.discord.lavaplayer.tools.FriendlyException +import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist +import com.sedmelluq.discord.lavaplayer.track.AudioTrack +import dev.arbjerg.lavalink.protocol.v3.LoadResult +import lavalink.server.util.toPlaylistInfo +import lavalink.server.util.toTrack +import org.slf4j.LoggerFactory +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.atomic.AtomicBoolean + +class AudioLoader(private val audioPlayerManager: AudioPlayerManager) : AudioLoadResultHandler { + + companion object { + private val log = LoggerFactory.getLogger(AudioLoader::class.java) + } + + private val loadResult = CompletableFuture() + private val used = AtomicBoolean(false) + + fun load(identifier: String?): CompletionStage { + val isUsed = used.getAndSet(true) + check(!isUsed) { "This loader can only be used once per instance" } + log.trace("Loading item with identifier $identifier") + audioPlayerManager.loadItem(identifier, this) + return loadResult + } + + override fun trackLoaded(audioTrack: AudioTrack) { + log.info("Loaded track ${audioTrack.info.title}") + val track = audioTrack.toTrack(audioPlayerManager) + loadResult.complete(LoadResult.trackLoaded(track)) + } + + override fun playlistLoaded(audioPlaylist: AudioPlaylist) { + log.info("Loaded playlist ${audioPlaylist.name}") + val tracks = audioPlaylist.tracks.map { it.toTrack(audioPlayerManager) } + if (audioPlaylist.isSearchResult) { + loadResult.complete(LoadResult.searchResultLoaded(tracks)) + return + } + loadResult.complete(LoadResult.playlistLoaded(audioPlaylist.toPlaylistInfo(), tracks)) + } + + override fun noMatches() { + log.info("No matches found") + loadResult.complete(LoadResult.noMatches) + } + + override fun loadFailed(e: FriendlyException) { + log.error("Load failed", e) + loadResult.complete(LoadResult.loadFailed(e)) + } + +} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.java b/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.java deleted file mode 100644 index 14aa32500..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.java +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright (c) 2021 Freya Arbjerg and contributors - * - * 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.player; - -import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo; -import lavalink.server.config.ServerConfig; -import lavalink.server.util.Util; -import org.json.JSONArray; -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; -import org.springframework.web.bind.annotation.RestController; - -import javax.servlet.http.HttpServletRequest; -import java.io.IOException; -import java.util.concurrent.CompletionStage; - -@RestController -public class AudioLoaderRestHandler { - - private static final Logger log = LoggerFactory.getLogger(AudioLoaderRestHandler.class); - private final AudioPlayerManager audioPlayerManager; - private final ServerConfig serverConfig; - - public AudioLoaderRestHandler(AudioPlayerManager audioPlayerManager, ServerConfig serverConfig) { - this.audioPlayerManager = audioPlayerManager; - this.serverConfig = serverConfig; - } - - private void log(HttpServletRequest request) { - String path = request.getServletPath(); - log.info("GET " + path); - } - - private JSONObject trackToJSON(AudioTrack audioTrack) { - AudioTrackInfo trackInfo = audioTrack.getInfo(); - - return new JSONObject() - .put("title", trackInfo.title) - .put("author", trackInfo.author) - .put("length", trackInfo.length) - .put("identifier", trackInfo.identifier) - .put("uri", trackInfo.uri) - .put("isStream", trackInfo.isStream) - .put("isSeekable", audioTrack.isSeekable()) - .put("position", audioTrack.getPosition()) - .put("sourceName", audioTrack.getSourceManager() == null ? null : audioTrack.getSourceManager().getSourceName()); - } - - private JSONObject encodeLoadResult(LoadResult result) { - JSONObject json = new JSONObject(); - JSONObject playlist = new JSONObject(); - JSONArray tracks = new JSONArray(); - - result.tracks.forEach(track -> { - JSONObject object = new JSONObject(); - object.put("info", trackToJSON(track)); - - try { - String encoded = Util.toMessage(audioPlayerManager, track); - object.put("track", encoded); - tracks.put(object); - } catch (IOException e) { - log.warn("Failed to encode a track {}, skipping", track.getIdentifier(), e); - } - }); - - playlist.put("name", result.playlistName); - playlist.put("selectedTrack", result.selectedTrack); - - json.put("playlistInfo", playlist); - json.put("loadType", result.loadResultType); - json.put("tracks", tracks); - - if (result.loadResultType == ResultStatus.LOAD_FAILED && result.exception != null) { - JSONObject exception = new JSONObject(); - exception.put("message", result.exception.getLocalizedMessage()); - exception.put("severity", result.exception.severity.toString()); - - json.put("exception", exception); - log.error("Track loading failed", result.exception); - } - - return json; - } - - @GetMapping(value = "/loadtracks", produces = "application/json") - @ResponseBody - public CompletionStage> getLoadTracks( - HttpServletRequest request, - @RequestParam String identifier) { - log.info("Got request to load for identifier \"{}\"", identifier); - - return new AudioLoader(audioPlayerManager).load(identifier) - .thenApply(this::encodeLoadResult) - .thenApply(loadResultJson -> new ResponseEntity<>(loadResultJson.toString(), HttpStatus.OK)); - } - - @GetMapping(value = "/decodetrack", produces = "application/json") - @ResponseBody - public ResponseEntity getDecodeTrack(HttpServletRequest request, @RequestParam String track) - throws IOException { - - log(request); - - AudioTrack audioTrack = Util.toAudioTrack(audioPlayerManager, track); - - return new ResponseEntity<>(trackToJSON(audioTrack).toString(), HttpStatus.OK); - } - - @PostMapping(value = "/decodetracks", consumes = "application/json", produces = "application/json") - @ResponseBody - public ResponseEntity postDecodeTracks(HttpServletRequest request, @RequestBody String body) - throws IOException { - - log(request); - - JSONArray requestJSON = new JSONArray(body); - JSONArray responseJSON = new JSONArray(); - - for (int i = 0; i < requestJSON.length(); i++) { - String track = requestJSON.getString(i); - AudioTrack audioTrack = Util.toAudioTrack(audioPlayerManager, track); - - JSONObject infoJSON = trackToJSON(audioTrack); - JSONObject trackJSON = new JSONObject() - .put("track", track) - .put("info", infoJSON); - - responseJSON.put(trackJSON); - } - - return new ResponseEntity<>(responseJSON.toString(), HttpStatus.OK); - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.kt b/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.kt new file mode 100644 index 000000000..291384df7 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/player/AudioLoaderRestHandler.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2021 Freya Arbjerg and contributors + * + * 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.player + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.JsonNodeFactory +import com.fasterxml.jackson.databind.node.ObjectNode +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager +import dev.arbjerg.lavalink.protocol.v3.Track +import dev.arbjerg.lavalink.protocol.v3.decodeTrack +import lavalink.server.util.toTrack +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.web.server.ResponseStatusException +import java.util.concurrent.CompletionStage +import javax.servlet.http.HttpServletRequest + +@RestController +class AudioLoaderRestHandler( + private val audioPlayerManager: AudioPlayerManager, + private val objectMapper: ObjectMapper +) { + + companion object { + private val log = LoggerFactory.getLogger(AudioLoaderRestHandler::class.java) + } + + @GetMapping(value = ["/loadtracks", "/v3/loadtracks"]) + fun loadTracks( + request: HttpServletRequest, + @RequestParam identifier: String + ): CompletionStage> { + log.info("Got request to load for identifier \"${identifier}\"") + return AudioLoader(audioPlayerManager).load(identifier).thenApply { + val node: ObjectNode = objectMapper.valueToTree(it) + if (request.servletPath.startsWith("/loadtracks") || request.servletPath.startsWith("/v3/loadtracks")) { + if (node.get("playlistInfo").isNull) { + node.replace("playlistInfo", JsonNodeFactory.instance.objectNode()) + } + + if (node.get("exception").isNull) { + node.remove("exception") + } + } + + return@thenApply ResponseEntity.ok(node) + } + } + + @GetMapping(value = ["/decodetrack", "/v3/decodetrack"]) + fun getDecodeTrack(@RequestParam encodedTrack: String?, @RequestParam track: String?): ResponseEntity { + val trackToDecode = encodedTrack ?: track ?: throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "No track to decode provided" + ) + return ResponseEntity.ok(decodeTrack(audioPlayerManager, trackToDecode).toTrack(trackToDecode)) + } + + @PostMapping(value = ["/decodetracks", "/v3/decodetracks"]) + fun decodeTracks(@RequestBody encodedTracks: List): ResponseEntity> { + if (encodedTracks.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "No tracks to decode provided") + } + return ResponseEntity.ok(encodedTracks.map { + decodeTrack(audioPlayerManager, it).toTrack(it) + }) + } + +} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/player/AudioLossCounter.java b/LavalinkServer/src/main/java/lavalink/server/player/AudioLossCounter.java deleted file mode 100644 index a2904e9d1..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/player/AudioLossCounter.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright (c) 2021 Freya Arbjerg and contributors - * - * 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.player; - -import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; -import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class AudioLossCounter extends AudioEventAdapter { - - private static final Logger log = LoggerFactory.getLogger(AudioLossCounter.class); - - public static final int EXPECTED_PACKET_COUNT_PER_MIN = (60 * 1000) / 20; // 20ms packets - private static final int ACCEPTABLE_TRACK_SWITCH_TIME = 100; //ms - - private long curMinute = 0; - private int curLoss = 0; - private int curSucc = 0; - - private int lastLoss = 0; - private int lastSucc = 0; - - private long playingSince = Long.MAX_VALUE; - private long lastTrackStarted = Long.MAX_VALUE / 2; - private long lastTrackEnded = Long.MAX_VALUE; - - AudioLossCounter() { - } - - void onLoss() { - checkTime(); - curLoss++; - } - - void onSuccess() { - checkTime(); - curSucc++; - } - - public int getLastMinuteLoss() { - return lastLoss; - } - - public int getLastMinuteSuccess() { - return lastSucc; - } - - public boolean isDataUsable() { - //log.info("\n" + lastTrackStarted + "\n" + lastTrackEnded + "\n" + playingSince); - - // Check that there isn't a significant gap in playback. If no track has ended yet, we can look past that - if(lastTrackStarted - lastTrackEnded > ACCEPTABLE_TRACK_SWITCH_TIME - && lastTrackEnded != Long.MAX_VALUE ) return false; - - // Check that we have at least stats for last minute - long lastMin = System.currentTimeMillis() / 60000 - 1; - //log.info((playingSince < lastMin * 60000) + ""); - return playingSince < lastMin * 60000; - } - - private void checkTime() { - long actualMinute = System.currentTimeMillis() / 60000; - - if(curMinute != actualMinute) { - lastLoss = curLoss; - lastSucc = curSucc; - curLoss = 0; - curSucc = 0; - curMinute = actualMinute; - } - } - - @Override - public void onTrackEnd(AudioPlayer __, AudioTrack ___, AudioTrackEndReason ____) { - lastTrackEnded = System.currentTimeMillis(); - } - - @Override - public void onTrackStart(AudioPlayer __, AudioTrack ___) { - lastTrackStarted = System.currentTimeMillis(); - - if (lastTrackStarted - lastTrackEnded > ACCEPTABLE_TRACK_SWITCH_TIME - || playingSince == Long.MAX_VALUE) { - playingSince = System.currentTimeMillis(); - lastTrackEnded = Long.MAX_VALUE; - } - } - - @Override - public void onPlayerPause(AudioPlayer player) { - onTrackEnd(null, null, null); - } - - @Override - public void onPlayerResume(AudioPlayer player) { - onTrackStart(null, null); - } - - @Override - public String toString() { - return "AudioLossCounter{" + - "lastLoss=" + lastLoss + - ", lastSucc=" + lastSucc + - ", total=" + (lastSucc + lastLoss) + - '}'; - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/player/AudioLossCounter.kt b/LavalinkServer/src/main/java/lavalink/server/player/AudioLossCounter.kt new file mode 100644 index 000000000..04ab56b07 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/player/AudioLossCounter.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2021 Freya Arbjerg and contributors + * + * 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.player + +import com.sedmelluq.discord.lavaplayer.player.event.* + +class AudioLossCounter : AudioEventListener { + companion object { + const val EXPECTED_PACKET_COUNT_PER_MIN = 60 * 1000 / 20 // 20ms packets + private const val ACCEPTABLE_TRACK_SWITCH_TIME = 100 //ms + } + + private var playingSince = Long.MAX_VALUE + private var lastTrackStarted = Long.MAX_VALUE / 2 + private var lastTrackEnded = Long.MAX_VALUE + + private var curMinute: Long = 0 + private var curLoss = 0 + private var curSucc = 0 + + var lastMinuteLoss = 0 + private set + var lastMinuteSuccess = 0 + private set + + fun onLoss() { + checkTime() + curLoss++ + } + + fun onSuccess() { + checkTime() + curSucc++ + } + + val isDataUsable: Boolean + get() { + // Check that there isn't a significant gap in playback. If no track has ended yet, we can look past that + if (lastTrackStarted - lastTrackEnded > ACCEPTABLE_TRACK_SWITCH_TIME && lastTrackEnded != Long.MAX_VALUE) { + return false + } + + // Check that we have at least stats for the last minute + val lastMin = System.currentTimeMillis() / 60000 - 1 + return playingSince < lastMin * 60000 + } + + private fun checkTime() { + val actualMinute = System.currentTimeMillis() / 60000 + if (curMinute != actualMinute) { + lastMinuteLoss = curLoss + lastMinuteSuccess = curSucc + curLoss = 0 + curSucc = 0 + curMinute = actualMinute + } + } + + override fun onEvent(event: AudioEvent) { + when (event) { + is PlayerPauseEvent, + is TrackEndEvent, + -> lastTrackEnded = System.currentTimeMillis() + + is PlayerResumeEvent, + is TrackStartEvent, + -> { + lastTrackStarted = System.currentTimeMillis() + if (lastTrackStarted - lastTrackEnded > ACCEPTABLE_TRACK_SWITCH_TIME || playingSince == Long.MAX_VALUE) { + playingSince = System.currentTimeMillis() + lastTrackEnded = Long.MAX_VALUE + } + } + } + } + + override fun toString(): String = buildString { + append("AudioLossCounter{") + append("lastLoss=$lastMinuteLoss, ") + append("lastSucc=$lastMinuteSuccess, ") + append("total=${lastMinuteSuccess + lastMinuteLoss}") + append('}') + } +} diff --git a/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java b/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java deleted file mode 100644 index 6486967c8..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.java +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright (c) 2021 Freya Arbjerg and contributors - * - * 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.player; - -import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; -import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; -import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter; -import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; -import lavalink.server.io.SocketServer; -import lavalink.server.util.Util; -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; - -public class EventEmitter extends AudioEventAdapter { - - private static final Logger log = LoggerFactory.getLogger(EventEmitter.class); - private final AudioPlayerManager audioPlayerManager; - private final Player linkPlayer; - - EventEmitter(AudioPlayerManager audioPlayerManager, Player linkPlayer) { - this.audioPlayerManager = audioPlayerManager; - this.linkPlayer = linkPlayer; - } - - @Override - public void onTrackStart(AudioPlayer player, AudioTrack track) { - JSONObject out = new JSONObject(); - out.put("op", "event"); - out.put("type", "TrackStartEvent"); - out.put("guildId", String.valueOf(linkPlayer.getGuildId())); - - try { - out.put("track", Util.toMessage(audioPlayerManager, track)); - } catch (IOException e) { - out.put("track", JSONObject.NULL); - } - - linkPlayer.getSocket().send(out); - } - - @Override - public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason) { - JSONObject out = new JSONObject(); - out.put("op", "event"); - out.put("type", "TrackEndEvent"); - out.put("guildId", String.valueOf(linkPlayer.getGuildId())); - try { - out.put("track", Util.toMessage(audioPlayerManager, track)); - } catch (IOException e) { - out.put("track", JSONObject.NULL); - } - - if (linkPlayer.getEndMarkerHit()) { - out.put("reason", AudioTrackEndReason.FINISHED.toString()); - linkPlayer.setEndMarkerHit(false); - } else { - out.put("reason", endReason.toString()); - } - - linkPlayer.getSocket().send(out); - } - - // These exceptions are already logged by Lavaplayer - @Override - public void onTrackException(AudioPlayer player, AudioTrack track, FriendlyException exception) { - JSONObject out = new JSONObject(); - out.put("op", "event"); - out.put("type", "TrackExceptionEvent"); - out.put("guildId", String.valueOf(linkPlayer.getGuildId())); - try { - out.put("track", Util.toMessage(audioPlayerManager, track)); - } catch (IOException e) { - out.put("track", JSONObject.NULL); - } - - out.put("error", exception.getMessage()); - JSONObject exceptionJson = new JSONObject(); - exceptionJson.put("message", exception.getMessage()); - exceptionJson.put("severity", exception.severity.toString()); - exceptionJson.put("cause", Util.getRootCause(exception).toString()); - out.put("exception", exceptionJson); - - linkPlayer.getSocket().send(out); - } - - @Override - public void onTrackStuck(AudioPlayer player, AudioTrack track, long thresholdMs) { - log.warn(track.getInfo().title + " got stuck! Threshold surpassed: " + thresholdMs); - - JSONObject out = new JSONObject(); - out.put("op", "event"); - out.put("type", "TrackStuckEvent"); - out.put("guildId", String.valueOf(linkPlayer.getGuildId())); - try { - out.put("track", Util.toMessage(audioPlayerManager, track)); - } catch (IOException e) { - out.put("track", JSONObject.NULL); - } - - out.put("thresholdMs", thresholdMs); - - linkPlayer.getSocket().send(out); - SocketServer.Companion.sendPlayerUpdate(linkPlayer.getSocket(), linkPlayer); - } - -} diff --git a/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.kt b/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.kt new file mode 100644 index 000000000..3b04be44c --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/player/EventEmitter.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2021 Freya Arbjerg and contributors + * + * 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.player + +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager +import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter +import com.sedmelluq.discord.lavaplayer.tools.FriendlyException +import com.sedmelluq.discord.lavaplayer.track.AudioTrack +import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason +import dev.arbjerg.lavalink.protocol.v3.Exception +import dev.arbjerg.lavalink.protocol.v3.Message +import dev.arbjerg.lavalink.protocol.v3.encodeTrack +import lavalink.server.io.SocketServer.Companion.sendPlayerUpdate +import lavalink.server.util.getRootCause +import org.slf4j.LoggerFactory + +class EventEmitter( + private val audioPlayerManager: AudioPlayerManager, + private val player: LavalinkPlayer +) : AudioEventAdapter() { + + companion object { + private val log = LoggerFactory.getLogger(EventEmitter::class.java) + } + + override fun onTrackStart(player: AudioPlayer, track: AudioTrack) { + val encodedTrack = encodeTrack(audioPlayerManager, track) + this.player.socket.sendMessage( + Message.TrackStartEvent(encodedTrack, encodedTrack, this.player.guildId.toString()) + ) + } + + override fun onTrackEnd(player: AudioPlayer, track: AudioTrack, endReason: AudioTrackEndReason) { + val reason = if (this.player.endMarkerHit) { + this.player.endMarkerHit = false + AudioTrackEndReason.FINISHED + } else { + endReason + } + + val encodedTrack = encodeTrack(audioPlayerManager, track) + this.player.socket.sendMessage( + Message.TrackEndEvent(encodedTrack, encodedTrack, reason, this.player.guildId.toString()) + ) + } + + // These exceptions are already logged by Lavaplayer + override fun onTrackException(player: AudioPlayer, track: AudioTrack, exception: FriendlyException) { + val encodedTrack = encodeTrack(audioPlayerManager, track) + this.player.socket.sendMessage( + Message.TrackExceptionEvent( + encodedTrack, + encodedTrack, + Exception(exception.message, exception.severity, getRootCause(exception).toString()), + this.player.guildId.toString() + ) + ) + } + + override fun onTrackStuck(player: AudioPlayer, track: AudioTrack, thresholdMs: Long) { + log.warn("${track.info.title} got stuck! Threshold surpassed: ${thresholdMs}ms") + val encodedTrack = encodeTrack(audioPlayerManager, track) + this.player.socket.sendMessage( + Message.TrackStuckEvent(encodedTrack, encodedTrack, thresholdMs, this.player.guildId.toString()) + ) + sendPlayerUpdate(this.player.socket, this.player) + } + +} diff --git a/LavalinkServer/src/main/java/lavalink/server/player/LavalinkPlayer.kt b/LavalinkServer/src/main/java/lavalink/server/player/LavalinkPlayer.kt new file mode 100644 index 000000000..e29fef25d --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/player/LavalinkPlayer.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2021 Freya Arbjerg and contributors + * + * 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.player + +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager +import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter +import com.sedmelluq.discord.lavaplayer.track.AudioTrack +import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason +import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame +import dev.arbjerg.lavalink.api.IPlayer +import dev.arbjerg.lavalink.api.ISocketContext +import io.netty.buffer.ByteBuf +import lavalink.server.config.ServerConfig +import lavalink.server.io.SocketContext +import lavalink.server.io.SocketServer.Companion.sendPlayerUpdate +import lavalink.server.player.filters.FilterChain +import moe.kyokobot.koe.MediaConnection +import moe.kyokobot.koe.media.OpusAudioFrameProvider +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +class LavalinkPlayer( + val socket: SocketContext, + private val guildId: Long, + private val serverConfig: ServerConfig, + audioPlayerManager: AudioPlayerManager, +) : AudioEventAdapter(), IPlayer { + val audioLossCounter = AudioLossCounter() + var endMarkerHit = false + var filters: FilterChain = FilterChain() + set(value) { + audioPlayer.setFilterFactory(value.takeIf { it.isEnabled }) + field = value + } + + private val audioPlayer: AudioPlayer = audioPlayerManager.createPlayer().also { + it.addListener(this) + it.addListener(EventEmitter(audioPlayerManager, this)) + it.addListener(audioLossCounter) + } + + private var updateFuture: ScheduledFuture<*>? = null + + fun destroy() { + audioPlayer.destroy() + } + + fun provideTo(connection: MediaConnection) { + connection.audioSender = Provider(connection) + } + + override fun isPlaying(): Boolean = audioPlayer.playingTrack != null && !audioPlayer.isPaused + + override fun getAudioPlayer(): AudioPlayer = audioPlayer + + override fun getTrack(): AudioTrack? = audioPlayer.playingTrack + + override fun getGuildId(): Long = guildId + + override fun getSocketContext(): ISocketContext = socket + + override fun play(track: AudioTrack) { + audioPlayer.playTrack(track) + sendPlayerUpdate(socket, this) + } + + override fun stop() { + audioPlayer.stopTrack() + } + + override fun setPause(b: Boolean) { + audioPlayer.isPaused = b + } + + override fun seekTo(position: Long) { + val track = audioPlayer.playingTrack ?: throw RuntimeException("Can't seek when not playing anything") + track.position = position + } + + override fun setVolume(volume: Int) { + audioPlayer.volume = volume + } + + override fun onTrackEnd(player: AudioPlayer, track: AudioTrack, endReason: AudioTrackEndReason) { + updateFuture!!.cancel(false) + } + + override fun onTrackStart(player: AudioPlayer, track: AudioTrack) { + if (updateFuture?.isCancelled == false) { + return + } + + updateFuture = socket.playerUpdateService.scheduleAtFixedRate( + { sendPlayerUpdate(socket, this) }, + 0, + serverConfig.playerUpdateInterval.toLong(), + TimeUnit.SECONDS + ) + } + + private inner class Provider(connection: MediaConnection?) : OpusAudioFrameProvider(connection) { + private var lastFrame: AudioFrame? = null + + override fun canProvide(): Boolean { + lastFrame = audioPlayer.provide() + return if (lastFrame == null) { + audioLossCounter.onLoss() + false + } else { + true + } + } + + override fun retrieveOpusFrame(buf: ByteBuf) { + audioLossCounter.onSuccess() + buf.writeBytes(lastFrame!!.data) + } + } +} diff --git a/LavalinkServer/src/main/java/lavalink/server/player/LoadResult.java b/LavalinkServer/src/main/java/lavalink/server/player/LoadResult.java deleted file mode 100644 index c8a93b9c6..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/player/LoadResult.java +++ /dev/null @@ -1,34 +0,0 @@ -package lavalink.server.player; - -import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; - -import javax.annotation.Nullable; -import java.util.Collections; -import java.util.List; - -class LoadResult { - public ResultStatus loadResultType; - public List tracks; - public String playlistName; - public Integer selectedTrack; - public FriendlyException exception; - - public LoadResult(ResultStatus loadResultType, List tracks, - @Nullable String playlistName, @Nullable Integer selectedTrack) { - - this.loadResultType = loadResultType; - this.tracks = Collections.unmodifiableList(tracks); - this.playlistName = playlistName; - this.selectedTrack = selectedTrack; - this.exception = null; - } - - public LoadResult(FriendlyException exception) { - this.loadResultType = ResultStatus.LOAD_FAILED; - this.tracks = Collections.emptyList(); - this.playlistName = null; - this.selectedTrack = null; - this.exception = exception; - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/player/Player.java b/LavalinkServer/src/main/java/lavalink/server/player/Player.java deleted file mode 100644 index 7005ed96d..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/player/Player.java +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright (c) 2021 Freya Arbjerg and contributors - * - * 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.player; - -import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; -import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; -import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason; -import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame; -import io.netty.buffer.ByteBuf; -import dev.arbjerg.lavalink.api.ISocketContext; -import lavalink.server.io.SocketContext; -import lavalink.server.io.SocketServer; -import lavalink.server.player.filters.FilterChain; -import lavalink.server.config.ServerConfig; -import moe.kyokobot.koe.MediaConnection; -import moe.kyokobot.koe.media.OpusAudioFrameProvider; -import org.json.JSONObject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import dev.arbjerg.lavalink.api.IPlayer; - -import javax.annotation.Nullable; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; - -public class Player extends AudioEventAdapter implements IPlayer { - - private static final Logger log = LoggerFactory.getLogger(Player.class); - - private final SocketContext socketContext; - private final long guildId; - private final ServerConfig serverConfig; - private final AudioPlayer player; - private final AudioLossCounter audioLossCounter = new AudioLossCounter(); - private AudioFrame lastFrame = null; - private FilterChain filters; - private ScheduledFuture myFuture = null; - private boolean endMarkerHit = false; - - public Player(SocketContext socketContext, long guildId, AudioPlayerManager audioPlayerManager, ServerConfig serverConfig) { - this.socketContext = socketContext; - this.guildId = guildId; - this.serverConfig = serverConfig; - this.player = audioPlayerManager.createPlayer(); - this.player.addListener(this); - this.player.addListener(new EventEmitter(audioPlayerManager, this)); - this.player.addListener(audioLossCounter); - } - - public void play(AudioTrack track) { - player.playTrack(track); - SocketServer.Companion.sendPlayerUpdate(socketContext, this); - } - - public void stop() { - player.stopTrack(); - } - - public void destroy() { - player.destroy(); - } - - public void setPause(boolean b) { - player.setPaused(b); - } - - @Override - public AudioPlayer getAudioPlayer() { - return player; - } - - @Override - public AudioTrack getTrack() { - return player.getPlayingTrack(); - } - - @Override - public long getGuildId() { - return guildId; - } - - @Override - public ISocketContext getSocketContext() { - return socketContext; - } - - public void seekTo(long 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) { - player.setVolume(volume); - } - - public void setEndMarkerHit(boolean hit) { - this.endMarkerHit = hit; - } - - public boolean getEndMarkerHit() { - return this.endMarkerHit; - } - - public JSONObject getState() { - JSONObject json = new JSONObject(); - - if (player.getPlayingTrack() != null) - json.put("position", player.getPlayingTrack().getPosition()); - json.put("time", System.currentTimeMillis()); - - return json; - } - - SocketContext getSocket() { - return socketContext; - } - - @Nullable - public AudioTrack getPlayingTrack() { - return player.getPlayingTrack(); - } - - public boolean isPaused() { - return player.isPaused(); - } - - public AudioLossCounter getAudioLossCounter() { - return audioLossCounter; - } - - private int getInterval() { - return serverConfig.getPlayerUpdateInterval(); - } - - public boolean isPlaying() { - return player.getPlayingTrack() != null && !player.isPaused(); - } - - @Override - public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason) { - myFuture.cancel(false); - } - - @Override - public void onTrackStart(AudioPlayer player, AudioTrack track) { - if (myFuture == null || myFuture.isCancelled()) { - myFuture = socketContext.getPlayerUpdateService().scheduleAtFixedRate(() -> { - if (socketContext.getSessionPaused()) return; - - SocketServer.Companion.sendPlayerUpdate(socketContext, this); - }, 0, this.getInterval(), TimeUnit.SECONDS); - } - } - - public void provideTo(MediaConnection connection) { - connection.setAudioSender(new Provider(connection)); - } - - private class Provider extends OpusAudioFrameProvider { - public Provider(MediaConnection connection) { - super(connection); - } - - @Override - public boolean canProvide() { - lastFrame = player.provide(); - - if(lastFrame == null) { - audioLossCounter.onLoss(); - return false; - } else { - return true; - } - } - - @Override - public void retrieveOpusFrame(ByteBuf buf) { - audioLossCounter.onSuccess(); - buf.writeBytes(lastFrame.getData()); - } - } - - @Nullable - public FilterChain getFilters() { - return filters; - } - - public void setFilters(FilterChain filters) { - this.filters = filters; - - if (filters.isEnabled()) { - player.setFilterFactory(filters); - } else { - player.setFilterFactory(null); - } - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/player/PlayerRestHandler.kt b/LavalinkServer/src/main/java/lavalink/server/player/PlayerRestHandler.kt new file mode 100644 index 000000000..4e01b7aa9 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/player/PlayerRestHandler.kt @@ -0,0 +1,189 @@ +package lavalink.server.player + +import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler +import com.sedmelluq.discord.lavaplayer.tools.FriendlyException +import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist +import com.sedmelluq.discord.lavaplayer.track.AudioTrack +import com.sedmelluq.discord.lavaplayer.track.TrackMarker +import dev.arbjerg.lavalink.api.AudioFilterExtension +import dev.arbjerg.lavalink.protocol.v3.* +import lavalink.server.config.ServerConfig +import lavalink.server.io.SocketServer +import lavalink.server.player.filters.FilterChain +import lavalink.server.util.* +import moe.kyokobot.koe.VoiceServerInfo +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.web.server.ResponseStatusException +import java.util.concurrent.CompletableFuture + +@RestController +class PlayerRestHandler( + private val socketServer: SocketServer, + private val filterExtensions: List, + serverConfig: ServerConfig, +) { + + companion object { + private val log = LoggerFactory.getLogger(PlayerRestHandler::class.java) + } + + val disabledFilters = serverConfig.filters.entries.filter { !it.value }.map { it.key } + + @GetMapping(value = ["/v3/sessions/{sessionId}/players"]) + private fun getPlayers(@PathVariable sessionId: String): ResponseEntity { + val context = socketContext(socketServer, sessionId) + + return ResponseEntity.ok(Players(context.players.values.map { it.toPlayer(context) })) + } + + @GetMapping(value = ["/v3/sessions/{sessionId}/players/{guildId}"]) + private fun getPlayer(@PathVariable sessionId: String, @PathVariable guildId: Long): ResponseEntity { + val context = socketContext(socketServer, sessionId) + val player = existingPlayer(context, guildId) + + return ResponseEntity.ok(player.toPlayer(context)) + } + + @PatchMapping(value = ["/v3/sessions/{sessionId}/players/{guildId}"]) + @ResponseStatus(HttpStatus.NO_CONTENT) + private fun patchPlayer( + @RequestBody playerUpdate: PlayerUpdate, + @PathVariable sessionId: String, + @PathVariable guildId: Long, + @RequestParam noReplace: Boolean = false + ): ResponseEntity { + val context = socketContext(socketServer, sessionId) + + if (playerUpdate.encodedTrack.isPresent && playerUpdate.identifier.isPresent) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot specify both encodedTrack and identifier") + } + + playerUpdate.filters.takeIfPresent { filters -> + val invalidFilters = filters.validate(disabledFilters) + + if (invalidFilters.isNotEmpty()) { + throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "Following filters are disabled in the config: ${invalidFilters.joinToString()}" + ) + } + } + + playerUpdate.voice.takeIfPresent { + //discord sometimes send a partial server update missing the endpoint, which can be ignored. + if (it.endpoint.isEmpty() || it.token.isEmpty() || it.sessionId.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Partial voice state update: $it") + } + } + + val player = context.getPlayer(guildId) + + playerUpdate.voice.takeIfPresent { + val oldConn = context.koe.getConnection(guildId) + if (oldConn == null || + oldConn.gatewayConnection?.isOpen == false || + oldConn.voiceServerInfo == null || + oldConn.voiceServerInfo?.endpoint != it.endpoint || + oldConn.voiceServerInfo?.token != it.token || + oldConn.voiceServerInfo?.sessionId != it.sessionId + ) { + //clear old connection + context.koe.destroyConnection(guildId) + + val conn = context.getMediaConnection(player) + conn.connect(VoiceServerInfo(it.sessionId, it.endpoint, it.token)).exceptionally { + throw ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to connect to voice server") + }.toCompletableFuture().join() + player.provideTo(conn) + } + } + + // we handle pause differently for playing new tracks + playerUpdate.paused.takeIf { it.isPresent && !playerUpdate.encodedTrack.isPresent && !playerUpdate.identifier.isPresent } + ?.let { + player.setPause(it.value) + } + + playerUpdate.volume.takeIfPresent { + player.setVolume(it) + } + + // we handle position differently for playing new tracks + playerUpdate.position.takeIf { it.isPresent && !playerUpdate.encodedTrack.isPresent && !playerUpdate.identifier.isPresent } + ?.let { + player.seekTo(it.value) + SocketServer.sendPlayerUpdate(context, player) + } + + playerUpdate.filters.takeIfPresent { + player.filters = FilterChain.parse(it, filterExtensions) + SocketServer.sendPlayerUpdate(context, player) + } + + if (playerUpdate.encodedTrack.isPresent || playerUpdate.identifier.isPresent) { + + if (noReplace && player.track != null) { + log.info("Skipping play request because of noReplace") + return ResponseEntity.ok(player.toPlayer(context)) + } + player.setPause(if (playerUpdate.paused.isPresent) playerUpdate.paused.value else false) + + val track: AudioTrack? = if (playerUpdate.encodedTrack.isPresent) { + playerUpdate.encodedTrack.value?.let { encodedTrack -> + decodeTrack(context.audioPlayerManager, encodedTrack) + } + } else { + val trackFuture = CompletableFuture() + context.audioPlayerManager.loadItem(playerUpdate.identifier.value, object : AudioLoadResultHandler { + override fun trackLoaded(track: AudioTrack) { + trackFuture.complete(track) + } + + override fun playlistLoaded(playlist: AudioPlaylist) { + trackFuture.completeExceptionally(ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot play a playlist or search result")) + } + + override fun noMatches() { + trackFuture.completeExceptionally(ResponseStatusException(HttpStatus.BAD_REQUEST, "No matches found for identifier")) + } + + override fun loadFailed(exception: FriendlyException) { + trackFuture.completeExceptionally(ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, exception.message, getRootCause(exception))) + } + }) + + trackFuture.exceptionally { + throw it + }.join() + } + + track?.let { + playerUpdate.position.takeIfPresent { position -> + track.position = position + } + + playerUpdate.endTime.takeIfPresent { endTime -> + if (endTime > 0) { + track.setMarker(TrackMarker(endTime, TrackEndMarkerHandler(player))) + } + } + + player.play(track) + player.provideTo(context.getMediaConnection(player)) + } ?: player.stop() + } + + return ResponseEntity.ok(player.toPlayer(context)) + } + + @DeleteMapping("/v3/sessions/{sessionId}/players/{guildId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + private fun deletePlayer(@PathVariable sessionId: String, @PathVariable guildId: Long) { + socketContext(socketServer, sessionId).destroyPlayer(guildId) + } +} + + diff --git a/LavalinkServer/src/main/java/lavalink/server/player/ResultStatus.java b/LavalinkServer/src/main/java/lavalink/server/player/ResultStatus.java deleted file mode 100644 index 5b427d35d..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/player/ResultStatus.java +++ /dev/null @@ -1,9 +0,0 @@ -package lavalink.server.player; - -public enum ResultStatus { - TRACK_LOADED, - PLAYLIST_LOADED, - SEARCH_RESULT, - NO_MATCHES, - LOAD_FAILED -} \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/player/TrackEndMarkerHandler.java b/LavalinkServer/src/main/java/lavalink/server/player/TrackEndMarkerHandler.kt similarity index 73% rename from LavalinkServer/src/main/java/lavalink/server/player/TrackEndMarkerHandler.java rename to LavalinkServer/src/main/java/lavalink/server/player/TrackEndMarkerHandler.kt index 8386ebcfe..dd4cbd292 100644 --- a/LavalinkServer/src/main/java/lavalink/server/player/TrackEndMarkerHandler.java +++ b/LavalinkServer/src/main/java/lavalink/server/player/TrackEndMarkerHandler.kt @@ -1,4 +1,3 @@ -package lavalink.server.player; /* * Copyright (c) 2021 Freya Arbjerg and contributors * @@ -20,23 +19,21 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +package lavalink.server.player +import com.sedmelluq.discord.lavaplayer.track.TrackMarkerHandler -import com.sedmelluq.discord.lavaplayer.track.TrackMarkerHandler; - -public class TrackEndMarkerHandler implements TrackMarkerHandler { - - private final Player player; - - public TrackEndMarkerHandler(Player player) { - this.player = player; +class TrackEndMarkerHandler(private val player: LavalinkPlayer) : TrackMarkerHandler { + companion object { + val APPLICABLE_STATES = listOf(TrackMarkerHandler.MarkerState.REACHED, TrackMarkerHandler.MarkerState.BYPASSED) } - @Override - public void handle(MarkerState state) { - if (state.equals(MarkerState.REACHED) | state.equals(MarkerState.BYPASSED)) { - player.setEndMarkerHit(true); - player.stop(); + override fun handle(state: TrackMarkerHandler.MarkerState) { + if (state !in APPLICABLE_STATES) { + return } + + player.endMarkerHit = true + player.stop() } } diff --git a/LavalinkServer/src/main/java/lavalink/server/player/filters/FilterChain.kt b/LavalinkServer/src/main/java/lavalink/server/player/filters/FilterChain.kt index 3c6e2802c..961d47bfa 100644 --- a/LavalinkServer/src/main/java/lavalink/server/player/filters/FilterChain.kt +++ b/LavalinkServer/src/main/java/lavalink/server/player/filters/FilterChain.kt @@ -1,6 +1,6 @@ package lavalink.server.player.filters -import com.google.gson.Gson +import com.fasterxml.jackson.databind.JsonNode import com.sedmelluq.discord.lavaplayer.filter.AudioFilter import com.sedmelluq.discord.lavaplayer.filter.FloatPcmAudioFilter import com.sedmelluq.discord.lavaplayer.filter.PcmFilterFactory @@ -8,57 +8,104 @@ import com.sedmelluq.discord.lavaplayer.filter.UniversalPcmAudioFilter import com.sedmelluq.discord.lavaplayer.format.AudioDataFormat import com.sedmelluq.discord.lavaplayer.track.AudioTrack import dev.arbjerg.lavalink.api.AudioFilterExtension -import org.json.JSONObject +import dev.arbjerg.lavalink.protocol.v3.* +import dev.arbjerg.lavalink.protocol.v3.Band -class FilterChain : PcmFilterFactory { +class FilterChain( + private val volume: VolumeConfig? = null, + private var equalizer: EqualizerConfig? = null, + private val karaoke: KaraokeConfig? = null, + private val timescale: TimescaleConfig? = null, + private val tremolo: TremoloConfig? = null, + private val vibrato: VibratoConfig? = null, + private val distortion: DistortionConfig? = null, + private val rotation: RotationConfig? = null, + private val channelMix: ChannelMixConfig? = null, + private val lowPass: LowPassConfig? = null, +) : PcmFilterFactory { - companion object { - private val gson = Gson() + @Volatile + private var pluginFilters: List = emptyList() - fun parse(json: JSONObject, extensions: List, config: Map): FilterChain { - config.forEach { if (!it.value) json.remove(it.key) } - return gson.fromJson(json.toString(), FilterChain::class.java)!! - .apply { parsePluginConfigs(json, extensions) } + companion object { + fun parse( + filters: Filters, + extensions: List, + ): FilterChain { + return FilterChain( + filters.volume?.let { VolumeConfig(it) }, + filters.equalizer?.let { + EqualizerConfig(it.map { band -> + lavalink.server.player.filters.Band( + band.band, + band.gain + ) + }) + }, + filters.karaoke?.let { KaraokeConfig(it.level, it.monoLevel, it.filterBand, it.filterWidth) }, + filters.timescale?.let { TimescaleConfig(it.speed, it.pitch, it.rate) }, + filters.tremolo?.let { TremoloConfig(it.frequency, it.depth) }, + filters.vibrato?.let { VibratoConfig(it.frequency, it.depth) }, + filters.distortion?.let { + DistortionConfig( + it.sinOffset, + it.sinScale, + it.cosOffset, + it.cosScale, + it.tanOffset, + it.tanScale, + it.offset, + it.scale + ) + }, + filters.rotation?.let { RotationConfig(it.rotationHz) }, + filters.channelMix?.let { + ChannelMixConfig( + it.leftToLeft, + it.leftToRight, + it.rightToLeft, + it.rightToRight + ) + }, + filters.lowPass?.let { LowPassConfig(it.smoothing) }, + ).apply { + parsePluginConfigs(filters.pluginFilters, extensions) + } } } - var volume: Float? = null - var equalizer: List? = null - private val karaoke: KaraokeConfig? = null - private val timescale: TimescaleConfig? = null - private val tremolo: TremoloConfig? = null - private val vibrato: VibratoConfig? = null - private val distortion: DistortionConfig? = null - private val rotation: RotationConfig? = null - private val channelMix: ChannelMixConfig? = null - private val lowPass: LowPassConfig? = null - @Transient - private var pluginFilters: List = emptyList() - - private fun parsePluginConfigs(json: JSONObject, extensions: List) { + private fun parsePluginConfigs(dynamicValues: Map, extensions: List) { pluginFilters = extensions.mapNotNull { - val obj = json.optJSONObject(it.name) ?: return@mapNotNull null - PluginConfig(it, obj) + val json = dynamicValues[it.name] ?: return@mapNotNull null + PluginConfig(it, json) } } + fun setEqualizer(equalizer: EqualizerConfig) { + this.equalizer = equalizer + } + private fun buildList() = listOfNotNull( - volume?.let { VolumeConfig(it) }, - equalizer?.let { EqualizerConfig(it) }, - karaoke, - timescale, - tremolo, - vibrato, - distortion, - rotation, - channelMix, - lowPass, - *pluginFilters.toTypedArray() + volume, + equalizer, + karaoke, + timescale, + tremolo, + vibrato, + distortion, + rotation, + channelMix, + lowPass, + *pluginFilters.toTypedArray() ) val isEnabled get() = buildList().any { it.isEnabled } - override fun buildChain(track: AudioTrack?, format: AudioDataFormat, output: UniversalPcmAudioFilter): MutableList { + override fun buildChain( + track: AudioTrack?, + format: AudioDataFormat, + output: UniversalPcmAudioFilter + ): MutableList { val enabledFilters = buildList().takeIf { it.isNotEmpty() } ?: return mutableListOf() @@ -72,10 +119,39 @@ class FilterChain : PcmFilterFactory { return pipeline.reversed().toMutableList() // Output last } - private class PluginConfig(val extension: AudioFilterExtension, val json: JSONObject) : FilterConfig() { + fun toFilters(): Filters { + return Filters( + volume?.volume, + equalizer?.bands?.map { Band(it.band, it.gain) }, + karaoke?.let { Karaoke(it.level, it.monoLevel, it.filterBand, it.filterWidth) }, + timescale?.let { Timescale(it.speed, it.pitch, it.rate) }, + tremolo?.let { Tremolo(it.frequency, it.depth) }, + vibrato?.let { Vibrato(it.frequency, it.depth) }, + distortion?.let { + Distortion( + it.sinOffset, + it.sinScale, + it.cosOffset, + it.cosScale, + it.tanOffset, + it.tanScale, + it.offset, + it.scale + ) + }, + rotation?.let { Rotation(it.rotationHz) }, + channelMix?.let { ChannelMix(it.leftToLeft, it.leftToRight, it.rightToLeft, it.rightToRight) }, + lowPass?.let { LowPass(it.smoothing) }, + pluginFilters.associate { it.extension.name to it.json } + ) + } + + private class PluginConfig(val extension: AudioFilterExtension, val json: JsonNode) : FilterConfig() { override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter = extension.build(json, format, output) + override val isEnabled = extension.isEnabled(json) + override val name: String = extension.name } } diff --git a/LavalinkServer/src/main/java/lavalink/server/player/filters/filterConfigs.kt b/LavalinkServer/src/main/java/lavalink/server/player/filters/filterConfigs.kt index b95ccdf7e..e6a5b4813 100644 --- a/LavalinkServer/src/main/java/lavalink/server/player/filters/filterConfigs.kt +++ b/LavalinkServer/src/main/java/lavalink/server/player/filters/filterConfigs.kt @@ -1,163 +1,169 @@ package lavalink.server.player.filters +import com.fasterxml.jackson.annotation.JsonIgnore import com.github.natanbc.lavadsp.channelmix.ChannelMixPcmAudioFilter +import com.github.natanbc.lavadsp.distortion.DistortionPcmAudioFilter import com.github.natanbc.lavadsp.karaoke.KaraokePcmAudioFilter +import com.github.natanbc.lavadsp.lowpass.LowPassPcmAudioFilter +import com.github.natanbc.lavadsp.rotation.RotationPcmAudioFilter import com.github.natanbc.lavadsp.timescale.TimescalePcmAudioFilter import com.github.natanbc.lavadsp.tremolo.TremoloPcmAudioFilter import com.github.natanbc.lavadsp.vibrato.VibratoPcmAudioFilter -import com.github.natanbc.lavadsp.distortion.DistortionPcmAudioFilter -import com.github.natanbc.lavadsp.lowpass.LowPassPcmAudioFilter -import com.github.natanbc.lavadsp.rotation.RotationPcmAudioFilter import com.github.natanbc.lavadsp.volume.VolumePcmAudioFilter -import com.sedmelluq.discord.lavaplayer.filter.AudioFilter import com.sedmelluq.discord.lavaplayer.filter.FloatPcmAudioFilter -import com.sedmelluq.discord.lavaplayer.filter.UniversalPcmAudioFilter -import com.sedmelluq.discord.lavaplayer.filter.equalizer.Equalizer import com.sedmelluq.discord.lavaplayer.format.AudioDataFormat +import com.sedmelluq.discord.lavaplayer.filter.equalizer.Equalizer as LavaplayerEqualizer -class VolumeConfig(private var volume: Float) : FilterConfig() { +class VolumeConfig(val volume: Float) : FilterConfig() { override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { - return VolumePcmAudioFilter(output, format.channelCount).also { + return VolumePcmAudioFilter(output).also { it.volume = volume } } override val isEnabled: Boolean get() = volume != 1.0f + override val name: String get() = "volume" } -class EqualizerConfig(bands: List) : FilterConfig() { - private val array = FloatArray(Equalizer.BAND_COUNT) { 0.0f } +data class Band(val band: Int, val gain: Float) + +class EqualizerConfig(val bands: List) : FilterConfig() { + private val array = FloatArray(LavaplayerEqualizer.BAND_COUNT) { 0.0f } init { bands.forEach { array[it.band] = it.gain } } override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter = - Equalizer(format.channelCount, output, array) + LavaplayerEqualizer(format.channelCount, output, array) override val isEnabled: Boolean get() = array.any { it != 0.0f } + override val name: String get() = "equalizer" } -data class Band(val band: Int, val gain: Float) - class KaraokeConfig( - private val level: Float = 1.0f, - private val monoLevel: Float = 1.0f, - private val filterBand: Float = 220.0f, - private val filterWidth: Float = 100.0f + val level: Float = 1.0f, + val monoLevel: Float = 1.0f, + val filterBand: Float = 220.0f, + val filterWidth: Float = 100.0f ) : FilterConfig() { override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { return KaraokePcmAudioFilter(output, format.channelCount, format.sampleRate) - .setLevel(level) - .setMonoLevel(monoLevel) - .setFilterBand(filterBand) - .setFilterWidth(filterWidth) + .setLevel(level) + .setMonoLevel(monoLevel) + .setFilterBand(filterBand) + .setFilterWidth(filterWidth) } + override val isEnabled: Boolean get() = true + override val name: String get() = "karaoke" } class TimescaleConfig( - private val speed: Double = 1.0, - private val pitch: Double = 1.0, - private val rate: Double = 1.0 + val speed: Double = 1.0, + val pitch: Double = 1.0, + val rate: Double = 1.0 ) : FilterConfig() { override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { return TimescalePcmAudioFilter(output, format.channelCount, format.sampleRate) - .setSpeed(speed) - .setPitch(pitch) - .setRate(rate) + .setSpeed(speed) + .setPitch(pitch) + .setRate(rate) } - override val isEnabled: Boolean get() = speed != 1.0 || pitch != 1.0 || rate != 1.0 + override val isEnabled: Boolean get() = speed != 1.0 || pitch != 1.0 || rate != 1.0 + override val name: String get() = "timescale" } class TremoloConfig( - private val frequency: Float = 2.0f, - private val depth: Float = 0.5f + val frequency: Float = 2.0f, + val depth: Float = 0.5f ) : FilterConfig() { override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { return TremoloPcmAudioFilter(output, format.channelCount, format.sampleRate) - .setFrequency(frequency) - .setDepth(depth) + .setFrequency(frequency) + .setDepth(depth) } override val isEnabled: Boolean get() = depth != 0.0f + override val name: String get() = "tremolo" } class VibratoConfig( - private val frequency: Float = 2.0f, - private val depth: Float = 0.5f + val frequency: Float = 2.0f, + val depth: Float = 0.5f ) : FilterConfig() { override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { return VibratoPcmAudioFilter(output, format.channelCount, format.sampleRate) - .setFrequency(frequency) - .setDepth(depth) + .setFrequency(frequency) + .setDepth(depth) } override val isEnabled: Boolean get() = depth != 0.0f - + override val name: String get() = "vibrato" } class DistortionConfig( - private val sinOffset: Float = 0.0f, - private val sinScale: Float = 1.0f, - private val cosOffset: Float = 0.0f, - private val cosScale: Float = 1.0f, - private val tanOffset: Float = 0.0f, - private val tanScale: Float = 1.0f, - private val offset: Float = 0.0f, - private val scale: Float = 1.0f + val sinOffset: Float = 0.0f, + val sinScale: Float = 1.0f, + val cosOffset: Float = 0.0f, + val cosScale: Float = 1.0f, + val tanOffset: Float = 0.0f, + val tanScale: Float = 1.0f, + val offset: Float = 0.0f, + val scale: Float = 1.0f ) : FilterConfig() { override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { return DistortionPcmAudioFilter(output, format.channelCount) - .setSinOffset(sinOffset) - .setSinScale(sinScale) - .setCosOffset(cosOffset) - .setCosScale(cosScale) - .setTanOffset(tanOffset) - .setTanScale(tanScale) - .setOffset(offset) - .setScale(scale) + .setSinOffset(sinOffset) + .setSinScale(sinScale) + .setCosOffset(cosOffset) + .setCosScale(cosScale) + .setTanOffset(tanOffset) + .setTanScale(tanScale) + .setOffset(offset) + .setScale(scale) } override val isEnabled: Boolean get() = sinOffset != 0.0f || sinScale != 1.0f || cosOffset != 0.0f || cosScale != 1.0f || tanOffset != 0.0f || tanScale != 1.0f || offset != 0.0f || scale != 1.0f - + override val name: String get() = "distortion" } class RotationConfig( - private val rotationHz: Double = 0.0 + val rotationHz: Double = 0.0 ) : FilterConfig() { override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { return RotationPcmAudioFilter(output, format.sampleRate) - .setRotationSpeed(rotationHz) + .setRotationSpeed(rotationHz) } override val isEnabled: Boolean get() = rotationHz != 0.0 + override val name: String get() = "rotation" } class ChannelMixConfig( - private val leftToLeft: Float = 1f, - private val leftToRight: Float = 0f, - private val rightToLeft: Float = 0f, - private val rightToRight: Float = 1f - + val leftToLeft: Float = 1f, + val leftToRight: Float = 0f, + val rightToLeft: Float = 0f, + val rightToRight: Float = 1f ) : FilterConfig() { override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { return ChannelMixPcmAudioFilter(output) - .setLeftToLeft(leftToLeft) - .setLeftToRight(leftToRight) - .setRightToLeft(rightToLeft) - .setRightToRight(rightToRight) + .setLeftToLeft(leftToLeft) + .setLeftToRight(leftToRight) + .setRightToLeft(rightToLeft) + .setRightToRight(rightToRight) } override val isEnabled: Boolean get() = leftToLeft != 1f || leftToRight != 0f || rightToLeft != 0f || rightToRight != 1f + override val name: String get() = "channelMix" } class LowPassConfig( - private val smoothing: Float = 20.0f + val smoothing: Float = 20.0f ) : FilterConfig() { override fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter { return LowPassPcmAudioFilter(output, format.channelCount) @@ -165,9 +171,15 @@ class LowPassConfig( } override val isEnabled: Boolean get() = smoothing > 1.0f + override val name: String get() = "lowPass" } abstract class FilterConfig { abstract fun build(format: AudioDataFormat, output: FloatPcmAudioFilter): FloatPcmAudioFilter + + @get:JsonIgnore abstract val isEnabled: Boolean + + @get:JsonIgnore + abstract val name: String } \ No newline at end of file diff --git a/LavalinkServer/src/main/java/lavalink/server/util/ResetableCountDownLatch.java b/LavalinkServer/src/main/java/lavalink/server/util/ResetableCountDownLatch.java deleted file mode 100644 index 3fa3ae2b5..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/util/ResetableCountDownLatch.java +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2021 Freya Arbjerg and contributors - * - * 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 org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; - -@SuppressWarnings("unused") -public class ResetableCountDownLatch { - - private static final Logger log = LoggerFactory.getLogger(ResetableCountDownLatch.class); - - private final int startCount; - private CountDownLatch latch; - - public ResetableCountDownLatch(int startCount) { - this.startCount = startCount; - latch = new CountDownLatch(startCount); - } - - public void countDown() { - latch.countDown(); - } - - public void await() throws InterruptedException { - latch.await(); - } - - public void await(long timeout, TimeUnit unit) throws InterruptedException { - latch.await(timeout, unit); - } - - public void reset() { - while (latch.getCount() != 0) latch.countDown(); - latch = new CountDownLatch(startCount); - } -} diff --git a/LavalinkServer/src/main/java/lavalink/server/util/Util.java b/LavalinkServer/src/main/java/lavalink/server/util/Util.java deleted file mode 100644 index c0b2b9b2c..000000000 --- a/LavalinkServer/src/main/java/lavalink/server/util/Util.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2021 Freya Arbjerg and contributors - * - * 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 com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager; -import com.sedmelluq.discord.lavaplayer.tools.io.MessageInput; -import com.sedmelluq.discord.lavaplayer.tools.io.MessageOutput; -import com.sedmelluq.discord.lavaplayer.track.AudioTrack; -import org.apache.commons.codec.binary.Base64; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; - -public class Util { - - public static int getShardFromSnowflake(String snowflake, int numShards) { - return (int) ((Long.parseLong(snowflake) >> 22) % numShards); - } - - public static AudioTrack toAudioTrack(AudioPlayerManager audioPlayerManager, String message) throws IOException { - byte[] b64 = Base64.decodeBase64(message); - ByteArrayInputStream bais = new ByteArrayInputStream(b64); - return audioPlayerManager.decodeTrack(new MessageInput(bais)).decodedTrack; - } - - public static String toMessage(AudioPlayerManager audioPlayerManager, AudioTrack track) throws IOException { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - audioPlayerManager.encodeTrack(new MessageOutput(baos), track); - return Base64.encodeBase64String(baos.toByteArray()); - } - - public static Throwable getRootCause(Throwable throwable) { - Throwable rootCause = throwable; - while (rootCause.getCause() != null) { - rootCause = rootCause.getCause(); - } - return rootCause; - } - -} diff --git a/LavalinkServer/src/main/java/lavalink/server/util/util.kt b/LavalinkServer/src/main/java/lavalink/server/util/util.kt new file mode 100644 index 000000000..67dd89c32 --- /dev/null +++ b/LavalinkServer/src/main/java/lavalink/server/util/util.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2021 Freya Arbjerg and contributors + * + * 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 com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager +import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist +import com.sedmelluq.discord.lavaplayer.track.AudioTrack +import dev.arbjerg.lavalink.protocol.v3.* +import lavalink.server.io.SocketContext +import lavalink.server.io.SocketServer +import lavalink.server.player.LavalinkPlayer +import org.springframework.http.HttpStatus +import org.springframework.web.server.ResponseStatusException + +fun AudioTrack.toTrack(audioPlayerManager: AudioPlayerManager): Track { + return this.toTrack(encodeTrack(audioPlayerManager, this)) +} + +fun AudioTrack.toTrack(encoded: String): Track { + return Track(encoded, encoded, this.toInfo()) +} + +fun AudioTrack.toInfo(): TrackInfo { + return TrackInfo( + this.identifier, + this.isSeekable, + this.info.author, + this.info.length, + this.info.isStream, + this.position, + this.info.title, + this.info.uri, + this.sourceManager.sourceName + ) +} + +fun AudioPlaylist.toPlaylistInfo(): PlaylistInfo { + return PlaylistInfo(this.name, this.tracks.indexOf(this.selectedTrack)) +} + +fun LavalinkPlayer.toPlayer(context: SocketContext): Player { + val connection = context.getMediaConnection(this).gatewayConnection + val voiceServerInfo = context.koe.getConnection(guildId)?.voiceServerInfo + + return Player( + guildId.toString(), + track?.toTrack(context.audioPlayerManager), + audioPlayer.volume, + audioPlayer.isPaused, + VoiceState( + voiceServerInfo?.token ?: "", + voiceServerInfo?.endpoint ?: "", + voiceServerInfo?.sessionId ?: "", + connection?.isOpen ?: false, + connection?.ping ?: -1 + ), + filters.toFilters(), + ) +} + +fun getRootCause(throwable: Throwable?): Throwable { + var rootCause = throwable + while (rootCause!!.cause != null) { + rootCause = rootCause.cause + } + return rootCause +} + +fun socketContext(socketServer: SocketServer, sessionId: String) = + socketServer.contextMap[sessionId] ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Session not found") + +fun existingPlayer(socketContext: SocketContext, guildId: Long) = + socketContext.players[guildId] ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Player not found") diff --git a/LavalinkServer/src/main/resources/application.yml b/LavalinkServer/src/main/resources/application.yml index 2b8568a9d..b032ed765 100644 --- a/LavalinkServer/src/main/resources/application.yml +++ b/LavalinkServer/src/main/resources/application.yml @@ -3,3 +3,9 @@ spring: version: "1.0" formatted-version: "(v1.0)" name: "Lavalink" +server: + error: + include-message: ALWAYS + include-binding-errors: ALWAYS + include-stacktrace: ON_PARAM + include-exception: false diff --git a/PLUGINS.md b/PLUGINS.md index aaeab50bb..7ffbf32df 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -19,6 +19,10 @@ for instructions. You can add your own plugin by submitting a pull-request to this file. ## Developing your own plugin + +> **Note:** +> If your plugin is developed in Kotlin make sure you are using **Kotlin v1.7.20** + Follow these steps to quickly get started with plugin development: 1. Create a copy of https://github.com/freyacodes/lavalink-plugin-template 2. Rename the directories `org/example/plugin/` under `src/main/java/` to something more specific like @@ -71,3 +75,15 @@ class MyMediaContainerProbe implements MediaContainerProbe { // ... } ``` + +To intercept and modify existing REST endpoints, you can implement the `RestInterceptor` interface: + +```java +import org.springframework.stereotype.Service; +import dev.arbjerg.lavalink.api.RestInterceptor; + +@Service +class TestRequestInterceptor implements RestInterceptor { + // ... +} +``` diff --git a/README.md b/README.md index 5eeb8ba70..4c695dbc5 100644 --- a/README.md +++ b/README.md @@ -48,34 +48,15 @@ Please see [here](CHANGELOG.md) ## Versioning policy -- The public API ("API" in a very broad sense) of Lavalink can be categorized into two main domains: - - **Client Domain:** The API exposed to clients, consisting of both the WebSocket protocol and any public HTTP endpoints - - **Server Domain:** The server application with its runtime environment, its configuration, etc. +Lavalink follows [Semantic Versioning](https://semver.org/). -- A change that is breaking to one domain might not be breaking at all to another. +Given a version number `MAJOR.MINOR.PATCH`, the following rules apply: - *Examples:* - - Removing an endpoint: This is a breaking change for the client domain but is not for running the server itself. - - Upgrading the minimum Java version: This is a breaking change for the server domain, but client implementations couldn't care less about it. + MAJOR breaking API changes + MINOR new backwards compatible features + PATCH backwards compatible bug fixes -**Given the above, the following versioning pattern lends itself well to the Lavalink project:** - -_**api.major.minor.patch**_ - -- **API**: Bumped when breaking changes are committed to the client domain of Lavalink - - *Examples:* Removing an endpoint, altering the output of an endpoint in a non-backward-compatible manner -- **Major**: Bumped when breaking changes are committed to the Lavalink server domain - - *Examples:* Bumping the required Java version, altering the configuration in a non-backward-compatible manner -- **Minor**: New features in any domain - - *Examples:* New optional endpoint or opcode, additional configuration options, change of large subsystems or dependencies -- **Patch**: Bug fixes in any domain - -Examples: Fixing a race condition, fixing unexpected exceptions, fixing output that is not according to specs, etc. - -While major, minor and patch will do an optimum effort to adhere to [Semantic Versioning](https://semver.org/), prepending it with an additional API version makes life easier for developers in two ways: It is a clear way for the Lavalink project to communicate the relevant breaking changes to client developers, and in return, client developers can use the API version to communicate to their users about the compatibility of their clients to the Lavalink server. +Additional labels for release candidates are available as extensions to the `MAJOR.MINOR.PATCH-rcNUMBER`(`3.6.0-rc1`) format. ## Client libraries: diff --git a/Testbot/src/main/kotlin/lavalink/testbot/testbot.kt b/Testbot/src/main/kotlin/lavalink/testbot/testbot.kt index 93276ed9c..24144fbe4 100644 --- a/Testbot/src/main/kotlin/lavalink/testbot/testbot.kt +++ b/Testbot/src/main/kotlin/lavalink/testbot/testbot.kt @@ -17,7 +17,6 @@ import net.dv8tion.jda.api.requests.GatewayIntent import net.dv8tion.jda.api.utils.cache.CacheFlag.* import org.slf4j.Logger import org.slf4j.LoggerFactory -import java.lang.IllegalArgumentException import java.net.URI private val log: Logger = LoggerFactory.getLogger("Testbot") diff --git a/build.gradle.kts b/build.gradle.kts index ad204621f..3d845acdd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,8 +14,8 @@ buildscript { classpath("org.springframework.boot:spring-boot-gradle-plugin:2.6.6") classpath("org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.6.2") classpath("com.adarshr:gradle-test-logger-plugin:1.6.0") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.3.61") - classpath("org.jetbrains.kotlin:kotlin-allopen:1.3.61") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.20") + classpath("org.jetbrains.kotlin:kotlin-allopen:1.7.20") } } @@ -41,7 +41,7 @@ subprojects { } tasks.withType { - kotlinOptions.jvmTarget = "1.8" + kotlinOptions.jvmTarget = "11" } tasks.withType { diff --git a/plugin-api/build.gradle.kts b/plugin-api/build.gradle.kts index b9801db7e..051890859 100644 --- a/plugin-api/build.gradle.kts +++ b/plugin-api/build.gradle.kts @@ -11,6 +11,7 @@ version = "3.6.1" dependencies { api(libs.spring.boot) + api(libs.spring.boot.web) api(libs.lavaplayer) api(libs.json) } diff --git a/plugin-api/src/main/java/dev/arbjerg/lavalink/api/AudioFilterExtension.java b/plugin-api/src/main/java/dev/arbjerg/lavalink/api/AudioFilterExtension.java index 8d23ba117..d374eeac6 100644 --- a/plugin-api/src/main/java/dev/arbjerg/lavalink/api/AudioFilterExtension.java +++ b/plugin-api/src/main/java/dev/arbjerg/lavalink/api/AudioFilterExtension.java @@ -1,5 +1,6 @@ package dev.arbjerg.lavalink.api; +import com.fasterxml.jackson.databind.JsonNode; import com.sedmelluq.discord.lavaplayer.filter.FloatPcmAudioFilter; import com.sedmelluq.discord.lavaplayer.format.AudioDataFormat; import org.json.JSONObject; @@ -21,12 +22,46 @@ public interface AudioFilterExtension { * @param format format as specified by Lavaplayer. * @param output the output to be used by the produced filter. * @return a filter which produces the desired audio effect. + * + * @deprecated As of v3.7 Jackson is the preferred way of JSON serialization, + * use {@link AudioFilterExtension#build(JsonNode, AudioDataFormat, FloatPcmAudioFilter)} instead. + */ + @Deprecated + default FloatPcmAudioFilter build(JSONObject data, AudioDataFormat format, FloatPcmAudioFilter output) { + return null; + } + + /** + * Builds a filter for a particular player. + * + * @param data JSON data received from the client under the extension name key. + * @param format format as specified by Lavaplayer. + * @param output the output to be used by the produced filter. + * @return a filter which produces the desired audio effect. + * */ - FloatPcmAudioFilter build(JSONObject data, AudioDataFormat format, FloatPcmAudioFilter output); + default FloatPcmAudioFilter build(JsonNode data, AudioDataFormat format, FloatPcmAudioFilter output) { + return build(new JSONObject(data.toString()), format, output); + } /** * @param data JSON data received from the client under the extension name key. * @return whether to build a filter. Returning false makes this extension do nothing. + * + * @deprecated As of v3.7 Jackson is the preferred way of JSON serialization, + * use {@link AudioFilterExtension#isEnabled(JsonNode)} instead. */ - boolean isEnabled(JSONObject data); + @Deprecated + default boolean isEnabled(JSONObject data) { + return false; + } + + /** + * @param data JSON data received from the client under the extension name key. + * @return whether to build a filter. Returning false makes this extension do nothing. + */ + default boolean isEnabled(JsonNode data) { + return isEnabled(new JSONObject(data.toString())); + } + } diff --git a/plugin-api/src/main/java/dev/arbjerg/lavalink/api/IPlayer.java b/plugin-api/src/main/java/dev/arbjerg/lavalink/api/IPlayer.java index edd6b0851..b05338d6a 100644 --- a/plugin-api/src/main/java/dev/arbjerg/lavalink/api/IPlayer.java +++ b/plugin-api/src/main/java/dev/arbjerg/lavalink/api/IPlayer.java @@ -2,19 +2,21 @@ import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import org.springframework.lang.Nullable; /** * Represents an audio player for a specific guild. Contains track data and is used for controlling playback. */ public interface IPlayer { /** - * @return the underlying Lavaplyer player + * @return the underlying Lavaplayer player */ AudioPlayer getAudioPlayer(); /** * @return the player's current track which is either playing or paused. May be null */ + @Nullable AudioTrack getTrack(); /** diff --git a/plugin-api/src/main/java/dev/arbjerg/lavalink/api/ISocketContext.java b/plugin-api/src/main/java/dev/arbjerg/lavalink/api/ISocketContext.java index 825304625..107437e76 100644 --- a/plugin-api/src/main/java/dev/arbjerg/lavalink/api/ISocketContext.java +++ b/plugin-api/src/main/java/dev/arbjerg/lavalink/api/ISocketContext.java @@ -1,6 +1,7 @@ package dev.arbjerg.lavalink.api; import org.json.JSONObject; +import org.springframework.lang.NonNull; import org.springframework.lang.Nullable; import java.util.Map; @@ -9,6 +10,13 @@ * Represents a WebSocket connection */ public interface ISocketContext { + + /** + * @return The session ID of the connection + */ + @NonNull + String getSessionId(); + /** * @return the User ID of the Client. */ @@ -40,9 +48,18 @@ public interface ISocketContext { /** * @param message a JSON message to send to the WebSocket client + * + * @deprecated As of v3.7 Jackson is the preferred way of JSON serialization, + * use {@link ISocketContext#sendMessage(Object)} instead. */ + @Deprecated void sendMessage(JSONObject message); + /** + * @param message a message to send to the WebSocket client, it should be compatible with Jackson. + */ + void sendMessage(Object message); + /** * @return the state of the context */ diff --git a/plugin-api/src/main/java/dev/arbjerg/lavalink/api/RestInterceptor.java b/plugin-api/src/main/java/dev/arbjerg/lavalink/api/RestInterceptor.java new file mode 100644 index 000000000..9fa76203e --- /dev/null +++ b/plugin-api/src/main/java/dev/arbjerg/lavalink/api/RestInterceptor.java @@ -0,0 +1,9 @@ +package dev.arbjerg.lavalink.api; + +import org.springframework.web.servlet.HandlerInterceptor; + +/** + * This interface allows intercepting HTTP requests to the Lavalink server. Override the methods to add your own logic. + */ +public interface RestInterceptor extends HandlerInterceptor { +} diff --git a/protocol/build.gradle.kts b/protocol/build.gradle.kts new file mode 100644 index 000000000..986ddade4 --- /dev/null +++ b/protocol/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + java + `java-library` + `maven-publish` + kotlin("jvm") +} + +val archivesBaseName = "protocol" +group = "dev.arbjerg.lavalink" +version = "3.7.0" + +java { + targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_11 + + withJavadocJar() + withSourcesJar() +} + +dependencies { + compileOnly(libs.lavaplayer) + implementation(libs.kotlin.stdlib.jdk8) + implementation(libs.jackson.module.kotlin) +} + +publishing { + publications { + create("Protocol") { + from(project.components["java"]) + + pom { + name.set("Lavalink Protocol") + description.set("Protocol for Lavalink Client development") + url.set("https://github.com/freyacodes/lavalink") + + licenses { + license { + name.set("The MIT License") + url.set("https://github.com/freyacodes/Lavalink/blob/master/LICENSE") + } + } + + developers { + developer { + id.set("freyacodes") + name.set("Freya Arbjerg") + url.set("https://www.arbjerg.dev") + } + } + + scm { + connection.set("scm:git:ssh://github.com/freyacodes/lavalink.git") + developerConnection.set("scm:git:ssh://github.com/freyacodes/lavalink.git") + url.set("https://github.com/freyacodes/lavalink") + } + } + } + } +} diff --git a/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/error.kt b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/error.kt new file mode 100644 index 000000000..7aeeccf2a --- /dev/null +++ b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/error.kt @@ -0,0 +1,13 @@ +package dev.arbjerg.lavalink.protocol.v3 + +import org.apache.http.HttpStatus +import java.time.Instant + +data class Error( + val timestamp: Instant, + val status: HttpStatus, + val error: String, + val trace: String? = null, + val message: String, + val path: String +) \ No newline at end of file diff --git a/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/filters.kt b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/filters.kt new file mode 100644 index 000000000..1356354a3 --- /dev/null +++ b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/filters.kt @@ -0,0 +1,120 @@ +package dev.arbjerg.lavalink.protocol.v3 + +import com.fasterxml.jackson.annotation.JsonAnyGetter +import com.fasterxml.jackson.annotation.JsonAnySetter +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.JsonNode + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class Filters( + val volume: Float? = null, + val equalizer: List? = null, + val karaoke: Karaoke? = null, + val timescale: Timescale? = null, + val tremolo: Tremolo? = null, + val vibrato: Vibrato? = null, + val distortion: Distortion? = null, + val rotation: Rotation? = null, + val channelMix: ChannelMix? = null, + val lowPass: LowPass? = null, + + @JsonAnyGetter + @JsonAnySetter + @get:JsonIgnore + val pluginFilters: Map = mutableMapOf() +) { + fun validate(disabledFilters: List): List { + val filters = mutableListOf() + if ("volume" in disabledFilters && volume != null) { + filters.add("volume") + } + if ("equalizer" in disabledFilters && equalizer != null) { + filters.add("equalizer") + } + if ("karaoke" in disabledFilters && karaoke != null) { + filters.add("karaoke") + } + if ("timescale" in disabledFilters && timescale != null) { + filters.add("timescale") + } + if ("tremolo" in disabledFilters && tremolo != null) { + filters.add("tremolo") + } + if ("vibrato" in disabledFilters && vibrato != null) { + filters.add("vibrato") + } + if ("distortion" in disabledFilters && distortion != null) { + filters.add("distortion") + } + if ("rotation" in disabledFilters && rotation != null) { + filters.add("rotation") + } + if ("channelMix" in disabledFilters && channelMix != null) { + filters.add("channelMix") + } + if ("lowPass" in disabledFilters && lowPass != null) { + filters.add("lowPass") + } + for (filter in pluginFilters) { + if (filter.key in disabledFilters) { + filters.add(filter.key) + } + } + return filters + } +} + +data class Band( + val band: Int, + val gain: Float = 1.0f +) + +data class Karaoke( + val level: Float = 1.0f, + val monoLevel: Float = 1.0f, + val filterBand: Float = 220.0f, + val filterWidth: Float = 100.0f +) + +data class Timescale( + val speed: Double = 1.0, + val pitch: Double = 1.0, + val rate: Double = 1.0 +) + +data class Tremolo( + val frequency: Float = 2.0f, + val depth: Float = 0.5f +) + +data class Vibrato( + val frequency: Float = 2.0f, + val depth: Float = 0.5f +) + +data class Distortion( + val sinOffset: Float = 0.0f, + val sinScale: Float = 1.0f, + val cosOffset: Float = 0.0f, + val cosScale: Float = 1.0f, + val tanOffset: Float = 0.0f, + val tanScale: Float = 1.0f, + val offset: Float = 0.0f, + val scale: Float = 1.0f +) + +data class Rotation( + val rotationHz: Double = 0.0 +) + +data class ChannelMix( + val leftToLeft: Float = 1.0f, + val leftToRight: Float = 0.0f, + val rightToLeft: Float = 0.0f, + val rightToRight: Float = 1.0f +) + +data class LowPass( + val smoothing: Float = 20.0f +) \ No newline at end of file diff --git a/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/info.kt b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/info.kt new file mode 100644 index 000000000..b1202fb40 --- /dev/null +++ b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/info.kt @@ -0,0 +1,50 @@ +package dev.arbjerg.lavalink.protocol.v3 + +import com.fasterxml.jackson.annotation.JsonValue + +data class Info( + val version: Version, + val buildTime: Long, + val git: Git, + val jvm: String, + val lavaplayer: String, + val sourceManagers: List, + val filters: List, + val plugins: Plugins +) + +data class Version( + val semver: String, + val major: Int, + val minor: Int, + val patch: Int, + val preRelease: String?, +) { + companion object { + + private val versionRegex = + Regex("""^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?""") + + fun fromSemver(semver: String): Version { + val match = versionRegex.matchEntire(semver) ?: return Version(semver, 0, 0, 0, null) + val major = match.groups["major"]!!.value.toInt() + val minor = match.groups["minor"]!!.value.toInt() + val patch = match.groups["patch"]!!.value.toInt() + val preRelease = match.groups["prerelease"]?.value + return Version(semver, major, minor, patch, preRelease) + } + } +} + +data class Git( + val branch: String, + val commit: String, + val commitTime: Long, +) + +data class Plugins( + @JsonValue + val plugins: List +) + +data class Plugin(val name: String, val version: String) \ No newline at end of file diff --git a/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/loadResult.kt b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/loadResult.kt new file mode 100644 index 000000000..e77e35846 --- /dev/null +++ b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/loadResult.kt @@ -0,0 +1,53 @@ +package dev.arbjerg.lavalink.protocol.v3 + +import com.sedmelluq.discord.lavaplayer.tools.FriendlyException + + +data class LoadResult( + val loadType: ResultStatus, + val tracks: List, + val playlistInfo: PlaylistInfo?, + val exception: Exception? +) { + companion object { + fun trackLoaded(track: Track) = LoadResult(ResultStatus.TRACK_LOADED, listOf(track), null, null) + fun playlistLoaded(playlistInfo: PlaylistInfo, tracks: List) = LoadResult( + ResultStatus.PLAYLIST_LOADED, + tracks, + playlistInfo, + null + ) + fun searchResultLoaded(tracks: List) = LoadResult(ResultStatus.SEARCH_RESULT, tracks, null, null) + val noMatches = LoadResult(ResultStatus.NO_MATCHES, emptyList(), null, null) + fun loadFailed(exception: FriendlyException) = + LoadResult(ResultStatus.LOAD_FAILED, emptyList(), null, Exception.fromFriendlyException(exception)) + + } +} + +data class PlaylistInfo( + val name: String, + val selectedTrack: Int +) + +data class Exception( + val message: String?, + val severity: FriendlyException.Severity, + val cause: String +) { + companion object { + fun fromFriendlyException(e: FriendlyException) = Exception( + e.message, + e.severity, + e.toString() + ) + } +} + +enum class ResultStatus { + TRACK_LOADED, + PLAYLIST_LOADED, + SEARCH_RESULT, + NO_MATCHES, + LOAD_FAILED +} \ No newline at end of file diff --git a/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/mapper.kt b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/mapper.kt new file mode 100644 index 000000000..e96f1f2cd --- /dev/null +++ b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/mapper.kt @@ -0,0 +1,12 @@ +package dev.arbjerg.lavalink.protocol.v3 + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule + +fun objectMapper(): ObjectMapper { + return ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .registerKotlinModule() + .setSerializationInclusion(JsonInclude.Include.NON_NULL) +} \ No newline at end of file diff --git a/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/messages.kt b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/messages.kt new file mode 100644 index 000000000..5514b5339 --- /dev/null +++ b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/messages.kt @@ -0,0 +1,129 @@ +package dev.arbjerg.lavalink.protocol.v3 + +import com.fasterxml.jackson.annotation.JsonUnwrapped +import com.fasterxml.jackson.annotation.JsonValue +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason + +class MessageDeserializer : StdDeserializer(Message::class.java) { + override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): Message { + val node: JsonNode = jp.codec.readTree(jp) + if (!node.has("op")) { + throw IllegalArgumentException("Message is missing op field") + } + + return when (Message.Op.valueOfIgnoreCase(node.get("op").asText())) { + Message.Op.Ready -> jp.codec.treeToValue(node, Message.ReadyEvent::class.java) + Message.Op.Stats -> jp.codec.treeToValue(node, Message.StatsEvent::class.java) + Message.Op.PlayerUpdate -> jp.codec.treeToValue(node, Message.PlayerUpdateEvent::class.java) + Message.Op.Event -> jp.codec.treeToValue(node, Message.Event::class.java) + } + } +} + +class EventDeserializer : StdDeserializer(Message.Event::class.java) { + override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): Message.Event { + val node: JsonNode = jp.codec.readTree(jp) + if (!node.has("type")) { + throw IllegalArgumentException("Message is missing type field") + } + + return when (Message.EventType.valueOfIgnoreCase(node.get("type").asText())) { + Message.EventType.TrackStart -> jp.codec.treeToValue(node, Message.TrackStartEvent::class.java) + Message.EventType.TrackEnd -> jp.codec.treeToValue(node, Message.TrackEndEvent::class.java) + Message.EventType.TrackException -> jp.codec.treeToValue(node, Message.TrackExceptionEvent::class.java) + Message.EventType.TrackStuck -> jp.codec.treeToValue(node, Message.TrackStuckEvent::class.java) + Message.EventType.WebSocketClosed -> jp.codec.treeToValue(node, Message.WebSocketClosedEvent::class.java) + } + } +} + +@JsonDeserialize(using = MessageDeserializer::class) +sealed class Message(val op: Op) { + + enum class Op(@JsonValue val value: String) { + Ready("ready"), + Stats("stats"), + PlayerUpdate("playerUpdate"), + Event("event"); + + companion object { + fun valueOfIgnoreCase(value: String): Op { + return values().first { it.value.equals(value, true) } + } + } + } + + enum class EventType(@JsonValue val value: String) { + TrackStart("TrackStartEvent"), + TrackEnd("TrackEndEvent"), + TrackException("TrackExceptionEvent"), + TrackStuck("TrackStuckEvent"), + WebSocketClosed("WebSocketClosedEvent"); + + companion object { + fun valueOfIgnoreCase(value: String): EventType { + return values().first { it.value.equals(value, true) } + } + } + } + + @JsonDeserialize(using = EventDeserializer::class) + sealed class Event( + val type: EventType, + open val guildId: String + ) : Message(Op.Event) + + data class ReadyEvent( + val resumed: Boolean, + val sessionId: String, + ) : Message(Op.Ready) + + data class PlayerUpdateEvent( + val state: PlayerState, + val guildId: String, + ) : Message(Op.PlayerUpdate) + + data class StatsEvent( + @JsonUnwrapped + val stats: Stats + ) : Message(Op.Stats) + + data class TrackStartEvent( + val encodedTrack: String, + val track: String, + override val guildId: String, + ) : Event(EventType.TrackStart, guildId) + + data class TrackEndEvent( + val encodedTrack: String, + val track: String, + val reason: AudioTrackEndReason, + override val guildId: String, + ) : Event(EventType.TrackEnd, guildId) + + data class TrackExceptionEvent( + val encodedTrack: String, + val track: String, + val exception: Exception, + override val guildId: String, + ) : Event(EventType.TrackException, guildId) + + data class TrackStuckEvent( + val encodedTrack: String, + val track: String, + val thresholdMs: Long, + override val guildId: String, + ) : Event(EventType.TrackStuck, guildId) + + data class WebSocketClosedEvent( + val code: Int, + val reason: String, + val byRemote: Boolean, + override val guildId: String, + ) : Event(EventType.WebSocketClosed, guildId) +} \ No newline at end of file diff --git a/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/omissible.kt b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/omissible.kt new file mode 100644 index 000000000..9cd839d56 --- /dev/null +++ b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/omissible.kt @@ -0,0 +1,80 @@ +package dev.arbjerg.lavalink.protocol.v3 + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.SerializerProvider +import com.fasterxml.jackson.databind.annotation.JsonDeserialize +import com.fasterxml.jackson.databind.annotation.JsonSerialize +import com.fasterxml.jackson.databind.deser.std.StdDeserializer +import com.fasterxml.jackson.databind.ser.std.StdSerializer + +@JsonDeserialize(using = OmissibleDeserializer::class) +@JsonSerialize(using = OmissibleSerializer::class) +interface Omissible { + val isPresent: Boolean + val value: T + + class Present(override val value: T) : Omissible { + override val isPresent = true + + override fun toString() = value.toString() + } + + object Omitted : Omissible { + override val isPresent = false + + override val value: Nothing + get() = error("Not present") + + override fun toString() = "OMITTED" + } + + companion object { + @Suppress("UNCHECKED_CAST") + fun omitted() = Omitted as Omissible + fun of(element: T) = Present(element) + } +} + +inline fun Omissible.takeIfPresent(function: (T) -> Unit) { + if (isPresent) function(value) +} + + +class OmissibleDeserializer(private val deserializer: (JsonParser, DeserializationContext) -> T) : + StdDeserializer>(Omissible::class.java) { + + @Suppress("unused") + constructor() : this({ jp, _ -> jp.readValueAs(object : TypeReference() {}) }) + + override fun deserialize(jp: JsonParser, ctxt: DeserializationContext): Omissible { + return if (jp.currentToken() == null) { + Omissible.omitted() + } else { + Omissible.of(deserializer(jp, ctxt)) + } + } + +} + +class OmissibleSerializer(private val serializer: (T, JsonGenerator, SerializerProvider) -> Unit) : + StdSerializer>(Omissible::class.java, false) { + + @Suppress("unused") + constructor() : this({ value, gen, _ -> gen.writePOJO(value) }) + + override fun isEmpty(provider: SerializerProvider?, value: Omissible): Boolean { + return value.isPresent.not() + } + + override fun serialize(value: Omissible, gen: JsonGenerator, provider: SerializerProvider) { + if (!value.isPresent) { + gen.writeNull() + } else { + serializer(value.value, gen, provider) + } + } +} + diff --git a/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/player.kt b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/player.kt new file mode 100644 index 000000000..ec1d0036b --- /dev/null +++ b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/player.kt @@ -0,0 +1,117 @@ +package dev.arbjerg.lavalink.protocol.v3 + +import com.fasterxml.jackson.annotation.JsonValue +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.annotation.JsonDeserialize + +data class Players( + @JsonValue + val players: List +) + +data class Player( + val guildId: String, + val track: Track?, + val volume: Int, + val paused: Boolean, + val voice: VoiceState, + val filters: Filters +) + +data class Track( + val encoded: String, + val track: String, + val info: TrackInfo +) + +data class TrackInfo( + val identifier: String, + val isSeekable: Boolean, + val author: String, + val length: Long, + val isStream: Boolean, + val position: Long, + val title: String, + val uri: String?, + val sourceName: String +) + +data class VoiceState( + val token: String = "", + val endpoint: String = "", + val sessionId: String = "", + val connected: Boolean = false, + val ping: Long = -1 +) + +data class PlayerState( + val time: Long, + val position: Long, + val connected: Boolean, + val ping: Long +) + +@JsonDeserialize(using = PlayerUpdateDeserializer::class) +data class PlayerUpdate( + val encodedTrack: Omissible = Omissible.omitted(), + val identifier: Omissible = Omissible.omitted(), + val position: Omissible = Omissible.omitted(), + val endTime: Omissible = Omissible.omitted(), + val volume: Omissible = Omissible.omitted(), + val paused: Omissible = Omissible.omitted(), + val filters: Omissible = Omissible.omitted(), + val voice: Omissible = Omissible.omitted() +) + +class PlayerUpdateDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): PlayerUpdate { + val node = p.codec.readTree(p) + + val encodedTrack = node.get("encodedTrack")?.let { + if (it.isNull) Omissible.of(null) else Omissible.of(it.asText()) + } ?: Omissible.omitted() + + val identifier = node.get("identifier")?.let { + Omissible.of(it.asText()) + } ?: Omissible.omitted() + + val position = node.get("position")?.let { + Omissible.of(it.asLong()) + } ?: Omissible.omitted() + + val endTime = node.get("endTime")?.let { + Omissible.of(it.asLong()) + } ?: Omissible.omitted() + + val volume = node.get("volume")?.let { + Omissible.of(it.asInt()) + } ?: Omissible.omitted() + + val paused = node.get("paused")?.let { + Omissible.of(it.asBoolean()) + } ?: Omissible.omitted() + + val filters = node.get("filters")?.let { + Omissible.of(p.codec.treeToValue(it, Filters::class.java)) + } ?: Omissible.omitted() + + val voice = node.get("voice")?.let { + Omissible.of(p.codec.treeToValue(it, VoiceState::class.java)) + } ?: Omissible.omitted() + + return PlayerUpdate( + encodedTrack, + identifier, + position, + endTime, + volume, + paused, + filters, + voice + ) + } + +} diff --git a/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/routeplanner.kt b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/routeplanner.kt new file mode 100644 index 000000000..88070bf63 --- /dev/null +++ b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/routeplanner.kt @@ -0,0 +1,36 @@ +package dev.arbjerg.lavalink.protocol.v3 + +data class RoutePlannerFreeAddress(val address: String) + +data class RoutePlannerStatus(val `class`: String?, val details: IRoutePlannerStatus?) + +interface IRoutePlannerStatus +data class GenericRoutePlannerStatus( + val ipBlock: IpBlockStatus, + val failingAddresses: List +) : IRoutePlannerStatus + +data class RotatingIpRoutePlannerStatus( + val ipBlock: IpBlockStatus, + val failingAddresses: List, + val rotateIndex: String, + val ipIndex: String, + val currentAddress: String +) : IRoutePlannerStatus + +data class NanoIpRoutePlannerStatus( + val ipBlock: IpBlockStatus, + val failingAddresses: List, + val currentAddressIndex: String +) : IRoutePlannerStatus + +data class RotatingNanoIpRoutePlannerStatus( + val ipBlock: IpBlockStatus, + val failingAddresses: List, + val blockIndex: String, + val currentAddressIndex: String +) : IRoutePlannerStatus + +data class FailingAddress(val failingAddress: String, val failingTimestamp: Long, val failingTime: String) +data class IpBlockStatus(val type: String, val size: String) + diff --git a/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/session.kt b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/session.kt new file mode 100644 index 000000000..feb0898fc --- /dev/null +++ b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/session.kt @@ -0,0 +1,38 @@ +package dev.arbjerg.lavalink.protocol.v3 + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.DeserializationContext +import com.fasterxml.jackson.databind.JsonDeserializer +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.annotation.JsonDeserialize + +data class Session( + val resumingKey: String? = null, + val timeout: Long, +) + +@JsonDeserialize(using = SessionUpdateDeserializer::class) +data class SessionUpdate( + val resumingKey: Omissible = Omissible.omitted(), + val timeout: Omissible = Omissible.omitted(), +) + +class SessionUpdateDeserializer : JsonDeserializer() { + override fun deserialize(p: JsonParser, ctxt: DeserializationContext): SessionUpdate { + val node = p.codec.readTree(p) + + val resumingKey = node.get("resumingKey")?.let { + if (it.isNull) Omissible.of(null) else Omissible.of(it.asText()) + } ?: Omissible.omitted() + + val timeout = node.get("timeout")?.let { + Omissible.of(it.asLong()) + } ?: Omissible.omitted() + + return SessionUpdate( + resumingKey, + timeout + ) + } + +} \ No newline at end of file diff --git a/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/stats.kt b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/stats.kt new file mode 100644 index 000000000..ac0ff828c --- /dev/null +++ b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/stats.kt @@ -0,0 +1,29 @@ +package dev.arbjerg.lavalink.protocol.v3 + +data class Stats( + val frameStats: FrameStats?, + val players: Int, + val playingPlayers: Int, + val uptime: Long, + val memory: Memory, + val cpu: Cpu, +) + +data class FrameStats( + val sent: Int, + val nulled: Int, + val deficit: Int +) + +data class Memory( + val free: Long, + val used: Long, + val allocated: Long, + val reservable: Long +) + +data class Cpu( + val cores: Int, + val systemLoad: Double, + val lavalinkLoad: Double +) \ No newline at end of file diff --git a/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/util.kt b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/util.kt new file mode 100644 index 000000000..a24c9c4cd --- /dev/null +++ b/protocol/src/main/java/dev/arbjerg/lavalink/protocol/v3/util.kt @@ -0,0 +1,21 @@ +package dev.arbjerg.lavalink.protocol.v3 + +import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager +import com.sedmelluq.discord.lavaplayer.tools.io.MessageInput +import com.sedmelluq.discord.lavaplayer.tools.io.MessageOutput +import com.sedmelluq.discord.lavaplayer.track.AudioTrack +import org.apache.commons.codec.binary.Base64 +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +fun decodeTrack(audioPlayerManager: AudioPlayerManager, message: String): AudioTrack { + val bais = ByteArrayInputStream(Base64.decodeBase64(message)) + return audioPlayerManager.decodeTrack(MessageInput(bais)).decodedTrack + ?: throw IllegalStateException("Failed to decode track due to a mismatching version or missing source manager") +} + +fun encodeTrack(audioPlayerManager: AudioPlayerManager, track: AudioTrack): String { + val baos = ByteArrayOutputStream() + audioPlayerManager.encodeTrack(MessageOutput(baos), track) + return Base64.encodeBase64String(baos.toByteArray()) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 312c29a6f..4a032cb84 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,7 @@ rootProject.name = "Lavalink-Parent" include(":Lavalink-Server") +include(":protocol") include(":Testbot") include(":plugin-api") include("plugin-api") @@ -31,6 +32,7 @@ fun VersionCatalogBuilder.spring() { library("spring-boot-web", "org.springframework.boot", "spring-boot-starter-web").versionRef("spring-boot") library("spring-boot-undertow", "org.springframework.boot", "spring-boot-starter-undertow") .versionRef("spring-boot") library("spring-boot-test", "org.springframework.boot", "spring-boot-starter-test") .versionRef("spring-boot") + library("jackson-module-kotlin", "com.fasterxml.jackson.module", "jackson-module-kotlin").version("2.13.2") bundle("spring", listOf("spring-websocket", "spring-boot-web", "spring-boot-undertow")) } @@ -66,7 +68,7 @@ fun VersionCatalogBuilder.metrics() { } fun VersionCatalogBuilder.common() { - version("kotlin", "1.3.61") + version("kotlin", "1.7.20") library("kotlin-reflect", "org.jetbrains.kotlin", "kotlin-reflect").versionRef("kotlin") library("kotlin-stdlib-jdk8", "org.jetbrains.kotlin", "kotlin-stdlib-jdk8").versionRef("kotlin") @@ -75,7 +77,6 @@ fun VersionCatalogBuilder.common() { library("sentry-logback", "io.sentry", "sentry-logback").version("1.7.2") library("oshi", "com.github.oshi", "oshi-core").version("5.7.4") library("json", "org.json", "json").version("20180813") - library("gson", "com.google.code.gson", "gson").version("2.8.5") library("spotbugs", "com.github.spotbugs", "spotbugs-annotations").version("3.1.6") }