Skip to content

Commit

Permalink
Merge branch 'master' into patch/plugin-api
Browse files Browse the repository at this point in the history
  • Loading branch information
topi314 committed Mar 10, 2024
2 parents 436763f + bb5e126 commit d8c2f1f
Show file tree
Hide file tree
Showing 22 changed files with 266 additions and 188 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ gradle.properties
application.yml
LavalinkServer/plugins
.cache/
site/
site/
.DS_Store
28 changes: 24 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,29 @@
Each release usually includes various fixes and improvements.
The most noteworthy of these, as well as any features and breaking changes, are listed here.

## v4.0.4
* Update Lavaplayer to `2.1.1`

## v4.0.3
* Fixed plugins not registering (introduced in [`4.0.2`](https://github.com/lavalink-devs/Lavalink/releases/tag/4.0.2))
* Fixed some issues where plugins would be redownloaded everytime lavalink started (introduced in [`4.0.1`](https://github.com/lavalink-devs/Lavalink/releases/tag/4.0.1))

## v4.0.2
* Fixed issue where all plugins get deleted when already present (introduced in [`v4.0.1`](https://github.com/lavalink-devs/Lavalink/releases/tag/4.0.1))
* Always include plugin info & user data when serializing (introduced in [`v4.0.1`](https://github.com/lavalink-devs/Lavalink/releases/tag/4.0.1))
* Updated oshi to `6.4.11`

## 4.0.1
* Updated Lavaplayer to `2.1.0`
* Updated oshi to `6.4.8`
* Fix/user data missing field exception in protocol
* Fix plugin manager not deleting old plugin version
* Fix not being able to seek when player is paused
* Removed illegal reflection notice

## 4.0.0
* Lavalink now requires Java 17 or higher to run
* **Removal of all websocket messages sent by the client. Everything is now done via [REST](../api/rest.md)**
* **Removal of all websocket messages sent by the client. Everything is now done via [REST](https://lavalink.dev/api/rest.html)**
* Remove default 4GB max heap allocation from docker image
* Removal of all `/v3` endpoints except `/version`. All other endpoints are now under `/v4`
* Reworked track loading result. For more info see [here](https://lavalink.dev/api/rest.md#track-loading-result)
Expand Down Expand Up @@ -51,17 +71,17 @@ The most noteworthy of these, as well as any features and breaking changes, are

## 4.0.0-beta.1
* New Lavalink now requires Java 17 or higher to run
* **Removal of all websocket messages sent by the client. Everything is now done via [REST](IMPLEMENTATION.md#rest-api)**
* **Removal of all websocket messages sent by the client. Everything is now done via [REST](https://lavalink.dev/api/rest.html)**
* Update to [Lavaplayer custom branch](https://github.com/Walkyst/lavaplayer-fork/tree/custom), which includes native support for artwork urls and ISRCs in the track info
* Addition of full `Track` objects in following events: `TrackStartEvent`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent`
* Resuming a session now requires the `Session-Id` header instead of `Resume-Key` header
* Reworked track loading result. For more info see [here](IMPLEMENTATION.md#track-loading-result)
* Reworked track loading result. For more info see [here](https://lavalink.dev/api/rest.html#track-loading-result)
* Update to the [Protocol Module](protocol) to support Kotlin/JS
* Removal of all `/v3` endpoints except `/version`. All other endpoints are now under `/v4`

> **Warning**
> This is a beta release, and as such, may contain bugs. Please report any bugs you find to the [issue tracker](https://github.com/lavalink-devs/Lavalink/issues/new/choose).
> For more info on the changes in this release, see [here](IMPLEMENTATION.md#significant-changes-v370---v400)
> For more info on the changes in this release, see [here](https://lavalink.dev/changelog/index.html#significant-changes)
> If you have any question regarding the changes in this release, please ask in the [support server](https://discord.gg/ZW4s47Ppw4) or [GitHub discussions](https://github.com/lavalink-devs/Lavalink/discussions/categories/q-a)
Contributors:
Expand Down
1 change: 0 additions & 1 deletion LavalinkServer/src/main/java/lavalink/server/Launcher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,6 @@ object Launcher {
}

val parent = launchPluginBootstrap()
log.info("You can safely ignore the big red warning about illegal reflection. See https://github.com/lavalink-devs/Lavalink/issues/295")
launchMain(parent, args)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,22 @@ import java.io.InputStream
import java.net.URL
import java.net.URLClassLoader
import java.nio.channels.Channels
import java.nio.file.Files
import java.util.*
import java.util.jar.JarFile
import java.util.regex.Pattern


@SpringBootApplication
class PluginManager(val config: PluginsConfig) {
companion object {
private val log: Logger = LoggerFactory.getLogger(PluginManager::class.java)
}

final val pluginManifests: MutableList<PluginManifest> = mutableListOf()
var classLoader: ClassLoader = PluginManager::class.java.classLoader

var classLoader = javaClass.classLoader

init {
manageDownloads()

pluginManifests.apply {
addAll(readClasspathManifests())
addAll(loadJars())
Expand All @@ -32,53 +34,61 @@ class PluginManager(val config: PluginsConfig) {

private fun manageDownloads() {
if (config.plugins.isEmpty()) return

val directory = File(config.pluginsDir)
directory.mkdir()

data class PluginJar(val name: String, val version: String, val file: File)

val pattern = Pattern.compile("(.+)-(.+)\\.jar$")
val pluginJars = directory.listFiles()!!.mapNotNull { f ->
val matcher = pattern.matcher(f.name)
if (!matcher.find()) return@mapNotNull null
PluginJar(matcher.group(1), matcher.group(2), f)
}

data class Declaration(val group: String, val name: String, val version: String, val repository: String)
val pluginJars = directory.listFiles()?.filter { it.extension == "jar" }
?.flatMap { file ->
JarFile(file).use { jar ->
loadPluginManifests(jar).map { manifest -> PluginJar(manifest, file) }
}
}
?.onEach { log.info("Found plugin '${it.manifest.name}' version ${it.manifest.version}") }
?: return

val declarations = config.plugins.map { declaration ->
if (declaration.dependency == null) throw RuntimeException("Illegal dependency declaration: null")
val fragments = declaration.dependency!!.split(":")
if (fragments.size != 3) throw RuntimeException("Invalid dependency \"${declaration.dependency}\"")

var repository = declaration.repository
?: if (declaration.snapshot) config.defaultPluginSnapshotRepository else config.defaultPluginRepository
repository = if (repository.endsWith("/")) repository else "$repository/"
Declaration(fragments[0], fragments[1], fragments[2], repository)
}
val repository = declaration.repository
?: config.defaultPluginSnapshotRepository.takeIf { declaration.snapshot }
?: config.defaultPluginRepository

Declaration(fragments[0], fragments[1], fragments[2], "${repository.removeSuffix("/")}/")
}.distinctBy { "${it.group}:${it.name}" }

for (declaration in declarations) {
val jars = pluginJars.filter { it.manifest.name == declaration.name }.takeIf { it.isNotEmpty() }
?: pluginJars.filter { matchName(it, declaration.name) }

var hasCurrentVersion = false

declarations.forEach declarationLoop@{ declaration ->
pluginJars.forEach { jar ->
if (declaration.name == jar.name) {
if (declaration.version == jar.version) {
// We already have this jar so don't redownload it
return@declarationLoop
}

// Delete jar of different versions
if (!jar.file.delete()) throw RuntimeException("Failed to delete ${jar.file.path}")
log.info("Deleted ${jar.file.path}")
for (jar in jars) {
if (jar.manifest.version == declaration.version) {
hasCurrentVersion = true
// Don't clean up the jar if it's a current version.
continue
}

// Delete versions of the plugin that aren't the same as declared version.
if (!jar.file.delete()) throw RuntimeException("Failed to delete ${jar.file.path}")
log.info("Deleted ${jar.file.path} (new version: ${declaration.version})")

}

val url = declaration.run { "$repository${group.replace(".", "/")}/$name/$version/$name-$version.jar" }
val file = File(directory, declaration.run { "$name-$version.jar" })
downloadJar(file, url)
if (!hasCurrentVersion) {
val url = declaration.url
val file = File(directory, declaration.canonicalJarName)
downloadJar(file, url)
}
}
}

private fun downloadJar(output: File, url: String) {
log.info("Downloading $url")

Channels.newChannel(URL(url).openStream()).use {
FileOutputStream(output).channel.transferFrom(it, 0, Long.MAX_VALUE)
}
Expand All @@ -87,71 +97,59 @@ class PluginManager(val config: PluginsConfig) {
private fun readClasspathManifests(): List<PluginManifest> {
return PathMatchingResourcePatternResolver()
.getResources("classpath*:lavalink-plugins/*.properties")
.map { r -> parsePluginManifest(r.inputStream) }
.map { parsePluginManifest(it.inputStream) }
.onEach { log.info("Found plugin '${it.name}' version ${it.version}") }
}

private fun loadJars(): List<PluginManifest> {
val directory = File(config.pluginsDir)
if (!directory.isDirectory) return emptyList()
val jarsToLoad = mutableListOf<File>()

Files.list(File(config.pluginsDir).toPath()).forEach { path ->
val file = path.toFile()
if (!file.isFile) return@forEach
if (file.extension != "jar") return@forEach
jarsToLoad.add(file)
}
val directory = File(config.pluginsDir).takeIf { it.isDirectory }
?: return emptyList()

if (jarsToLoad.isEmpty()) return emptyList()
val jarsToLoad = directory.listFiles()?.filter { it.isFile && it.extension == "jar" }
?.takeIf { it.isNotEmpty() }
?: return emptyList()

val cl = URLClassLoader.newInstance(
classLoader = URLClassLoader.newInstance(
jarsToLoad.map { URL("jar:file:${it.absolutePath}!/") }.toTypedArray(),
javaClass.classLoader
)
classLoader = cl

val manifests = mutableListOf<PluginManifest>()
return jarsToLoad.flatMap { loadJar(it, classLoader) }
}

jarsToLoad.forEach { file ->
try {
manifests.addAll(loadJar(file, cl))
} catch (e: Exception) {
throw RuntimeException("Error loading $file", e)
}
}
private fun loadJar(file: File, cl: ClassLoader): List<PluginManifest> {
val jar = JarFile(file)
val manifests = loadPluginManifests(jar)
var classCount = 0

jar.use {
if (manifests.isEmpty()) {
throw RuntimeException("No plugin manifest found in ${file.path}")
}

return manifests
}
val allowedPaths = manifests.map { manifest -> manifest.path.replace(".", "/") }

private fun loadJar(file: File, cl: URLClassLoader): MutableList<PluginManifest> {
var classCount = 0
val jar = JarFile(file)
val manifests = mutableListOf<PluginManifest>()
for (entry in it.entries()) {
if (entry.isDirectory ||
!entry.name.endsWith(".class") ||
allowedPaths.none(entry.name::startsWith)) continue

jar.entries().asIterator().forEach { entry ->
if (entry.isDirectory) return@forEach
if (!entry.name.startsWith("lavalink-plugins/")) return@forEach
if (!entry.name.endsWith(".properties")) return@forEach
manifests.add(parsePluginManifest(jar.getInputStream(entry)))
cl.loadClass(entry.name.dropLast(6).replace("/", "."))
classCount++
}
}

if (manifests.isEmpty()) {
throw RuntimeException("No plugin manifest found in ${file.path}")
}
val allowedPaths = manifests.map { it.path.replace(".", "/") }

jar.entries().asIterator().forEach { entry ->
if (entry.isDirectory) return@forEach
if (!entry.name.endsWith(".class")) return@forEach
if (!allowedPaths.any { entry.name.startsWith(it) }) return@forEach
cl.loadClass(entry.name.dropLast(6).replace("/", "."))
classCount++
}
log.info("Loaded ${file.name} ($classCount classes)")
return manifests
}

private fun loadPluginManifests(jar: JarFile): List<PluginManifest> {
return jar.entries().asSequence()
.filter { !it.isDirectory && it.name.startsWith("lavalink-plugins/") && it.name.endsWith(".properties") }
.map { parsePluginManifest(jar.getInputStream(it)) }
.toList()
}

private fun parsePluginManifest(stream: InputStream): PluginManifest {
val props = stream.use {
Properties().apply { load(it) }
Expand All @@ -160,11 +158,24 @@ class PluginManager(val config: PluginsConfig) {
val name = props.getProperty("name") ?: throw RuntimeException("Manifest is missing 'name'")
val path = props.getProperty("path") ?: throw RuntimeException("Manifest is missing 'path'")
val version = props.getProperty("version") ?: throw RuntimeException("Manifest is missing 'version'")
log.info("Found plugin '$name' version $version")
return PluginManifest(name, path, version)
}

companion object {
private val log: Logger = LoggerFactory.getLogger(PluginManager::class.java)
private fun matchName(jar: PluginJar, name: String): Boolean {
// removeSuffix removes names ending with "-v", such as -v1.0.0
// and then the subsequent removeSuffix call removes trailing "-", which
// usually precedes a version number, such as my-plugin-1.0.0.
// We strip these to produce the name of the jar's file.
val jarName = jar.file.nameWithoutExtension.takeWhile { !it.isDigit() }
.removeSuffix("-v")
.removeSuffix("-")

return name == jarName
}

private data class PluginJar(val manifest: PluginManifest, val file: File)
private data class Declaration(val group: String, val name: String, val version: String, val repository: String) {
val canonicalJarName = "$name-$version.jar"
val url = "$repository${group.replace(".", "/")}/$name/$version/$name-$version.jar"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,32 @@ package lavalink.server.config

import lavalink.server.io.HandshakeInterceptorImpl
import lavalink.server.io.SocketServer
import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Configuration
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController
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
@RestController
class WebsocketConfig(
private val server: SocketServer,
private val handshakeInterceptor: HandshakeInterceptorImpl,
) : WebSocketConfigurer {

companion object {
private val log = LoggerFactory.getLogger(WebsocketConfig::class.java)
}

override fun registerWebSocketHandlers(registry: WebSocketHandlerRegistry) {
registry.addHandler(server, "/v4/websocket").addInterceptors(handshakeInterceptor)
}

@GetMapping("/", "/v3/websocket")
fun oldWebsocket() {
log.warn("This is the old Lavalink websocket endpoint. Please use /v4/websocket instead. If you are using a client library, please update it to a Lavalink v4 compatible version or use Lavalink v3 instead.")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ class PlayerRestHandler(
// we handle position differently for playing new tracks
playerUpdate.position.takeIfPresent { encodedTrack is Omissible.Omitted && identifier is Omissible.Omitted }
?.let {
if (player.isPlaying) {
if (player.track != null) {
player.seekTo(it)
SocketServer.sendPlayerUpdate(context, player)
}
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ A [basic example bot](Testbot) is available.
* Basic authentication
* Prometheus metrics
* Docker images
* [Plugin support](PLUGINS.md)
* [Plugin support](https://lavalink.dev/plugins.html)

## Requirements

Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ subprojects {
val snapshots = "https://maven.lavalink.dev/snapshots"
val releases = "https://maven.lavalink.dev/releases"

maven(if ((version as String).endsWith("-SNAPSHOT")) releases else snapshots) {
maven(if ((version as String).endsWith("-SNAPSHOT")) snapshots else releases) {
credentials {
password = findProperty("MAVEN_PASSWORD") as String?
username = findProperty("MAVEN_USERNAME") as String?
Expand Down
4 changes: 2 additions & 2 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ What happens after your client disconnects is dependent on whether the session h
* 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 `resuming` and `timeout`.
To enable resuming, you must call the [Update Session](../api/rest.md#update-session) endpoint with the `resuming` and `timeout`.

To resume a session, specify the session id in your WebSocket handshake request headers:

Expand All @@ -36,7 +36,7 @@ You can tell if your session was resumed by looking at the handshake response he
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.
In case your websocket library doesn't support reading headers you can listen for the [ready op](../api/websocket.md#ready-op) and check the `resumed` field.

When a session is paused, any events that would normally have been sent are queued up. When the session is resumed, this
queue is then emptied and the events are replayed.
Expand Down
Loading

0 comments on commit d8c2f1f

Please sign in to comment.