From 9ebf59de7650b4af0b53a8b63cdfb5ce55c211be Mon Sep 17 00:00:00 2001
From: Michael Rittmeister <michael@rittmeister.in>
Date: Fri, 22 Dec 2023 00:06:54 +0100
Subject: [PATCH] Add lyrics plugin

---
 .../mikbot/plugin/api/util/Confirmation.kt    |  12 +-
 gradle/libs.versions.toml                     |   6 +-
 music/build.gradle.kts                        |   2 +-
 music/commands/build.gradle.kts               |   1 -
 .../schlaubi/mikmusic/commands/Commands.kt    |   5 -
 .../mikmusic/commands/LycricsCommand.kt       |  60 -------
 music/lyrics/build.gradle.kts                 |  37 ++++
 music/lyrics/src/main/kotlin/APIServer.kt     | 165 ++++++++++++++++++
 music/lyrics/src/main/kotlin/Config.kt        |   7 +
 .../lyrics/src/main/kotlin/KaraokeCommand.kt  |  34 ++++
 music/lyrics/src/main/kotlin/LyricsCommand.kt |  74 ++++++++
 music/lyrics/src/main/kotlin/LyricsPlugin.kt  |  24 +++
 music/lyrics/src/main/kotlin/events/Events.kt |  46 +++++
 .../lyrics/strings_de_DE.properties           |   4 +
 .../lyrics/strings_en_GB.properties           |   4 +
 music/player/build.gradle.kts                 |   1 +
 .../autocomplete/AutoCompleteArgument.kt      |   6 +-
 .../mikmusic/player/queue/QueueResponse.kt    |   2 +-
 runtime/plugins.txt                           |   4 +-
 settings.gradle.kts                           |   1 +
 20 files changed, 418 insertions(+), 77 deletions(-)
 delete mode 100644 music/commands/src/main/kotlin/dev/schlaubi/mikmusic/commands/LycricsCommand.kt
 create mode 100644 music/lyrics/build.gradle.kts
 create mode 100644 music/lyrics/src/main/kotlin/APIServer.kt
 create mode 100644 music/lyrics/src/main/kotlin/Config.kt
 create mode 100644 music/lyrics/src/main/kotlin/KaraokeCommand.kt
 create mode 100644 music/lyrics/src/main/kotlin/LyricsCommand.kt
 create mode 100644 music/lyrics/src/main/kotlin/LyricsPlugin.kt
 create mode 100644 music/lyrics/src/main/kotlin/events/Events.kt
 create mode 100644 music/lyrics/src/main/resources/translations/lyrics/strings_de_DE.properties
 create mode 100644 music/lyrics/src/main/resources/translations/lyrics/strings_en_GB.properties

diff --git a/api/src/main/kotlin/dev/schlaubi/mikbot/plugin/api/util/Confirmation.kt b/api/src/main/kotlin/dev/schlaubi/mikbot/plugin/api/util/Confirmation.kt
index d3db6c8f3..4e1d286a0 100644
--- a/api/src/main/kotlin/dev/schlaubi/mikbot/plugin/api/util/Confirmation.kt
+++ b/api/src/main/kotlin/dev/schlaubi/mikbot/plugin/api/util/Confirmation.kt
@@ -16,7 +16,7 @@ import dev.kord.core.entity.interaction.ComponentInteraction
 import dev.kord.core.entity.interaction.followup.EphemeralFollowupMessage
 import dev.kord.core.entity.interaction.followup.PublicFollowupMessage
 import dev.kord.core.event.interaction.InteractionCreateEvent
-import dev.kord.rest.builder.message.create.actionRow
+import dev.kord.rest.builder.message.actionRow
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.seconds
 
@@ -117,7 +117,15 @@ private suspend fun CommandContext.confirmation(
     timeout: Duration = 30.seconds,
     acknowledge: Boolean = true,
     messageBuilder: MessageBuilder,
-): Confirmation = confirmation(sendMessage, timeout, messageBuilder, translate = ::translate, yesWord = yesWord, noWord = noWord, acknowledge = acknowledge)
+): Confirmation = confirmation(
+    sendMessage,
+    timeout,
+    messageBuilder,
+    translate = ::translate,
+    yesWord = yesWord,
+    noWord = noWord,
+    acknowledge = acknowledge
+)
 
 /**
  * Bare bone confirmation implementation.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 431e5a0da..ae3fe2029 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -4,12 +4,12 @@ kordex = "1.6.0-SNAPSHOT"
 kmongo = "4.11.0"
 coroutines = "1.7.3"
 serialization = "1.6.0"
-ktor = "2.3.6"
+ktor = "2.3.7"
 kord = "0.12.0"
 jjwt = "0.11.5"
 api = "3.26.0"
 ksp = "2.0.0-Beta1-1.0.14"
-lavakord = "main-SNAPSHOT"
+lavakord = "6.1.2"
 
 [libraries]
 kord-common = { group = "dev.kord", name = "kord-common", version.ref = "kord" }
@@ -34,6 +34,7 @@ lavakord-kord = { group = "dev.schlaubi.lavakord", name = "kord", version.ref =
 lavakord-sponsorblock = { group = "dev.schlaubi.lavakord", name = "sponsorblock", version.ref = "lavakord" }
 lavakord-lavsrc = { group = "dev.schlaubi.lavakord", name = "lavasrc-jvm", version.ref = "lavakord" }
 lavakord-lavasearch = { group = "dev.schlaubi.lavakord", name = "lavasearch-jvm", version.ref = "lavakord" }
+lavakord-lyrics = { group = "dev.schlaubi.lavakord", name = "lyrics-jvm", version.ref = "lavakord" }
 spotify = { group = "se.michaelthelin.spotify", name = "spotify-web-api-java", version = "8.0.0" }
 krontab = { group = "dev.inmo", name = "krontab", version = "0.10.0" }
 ksp-api = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref = "ksp" }
@@ -53,6 +54,7 @@ ktor-server-auth = { group = "io.ktor", name = "ktor-server-auth", version.ref =
 ktor-server-sessions = { group = "io.ktor", name = "ktor-server-sessions", version.ref = "ktor" }
 ktor-server-core = { group = "io.ktor", name = "ktor-server-core-jvm", version.ref = "ktor" }
 ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors", version.ref = "ktor" }
+ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
 ktor-server-html-builder = { group = "io.ktor", name = "ktor-server-html-builder", version.ref = "ktor" }
 github-repositories = { group = "dev.nycode.github", name = "repositories", version = "1.0.0-SNAPSHOT" }
 kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version = "0.4.1" }
diff --git a/music/build.gradle.kts b/music/build.gradle.kts
index 2ed503892..82158f317 100644
--- a/music/build.gradle.kts
+++ b/music/build.gradle.kts
@@ -1,3 +1,3 @@
 subprojects {
-    version = "3.4.1-SNAPSHOT"
+    version = "3.5.0-SNAPSHOT"
 }
diff --git a/music/commands/build.gradle.kts b/music/commands/build.gradle.kts
index 6adef367c..c00f509c9 100644
--- a/music/commands/build.gradle.kts
+++ b/music/commands/build.gradle.kts
@@ -18,7 +18,6 @@ tasks {
             freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers"
         }
     }
-
 }
 
 mikbotPlugin {
diff --git a/music/commands/src/main/kotlin/dev/schlaubi/mikmusic/commands/Commands.kt b/music/commands/src/main/kotlin/dev/schlaubi/mikmusic/commands/Commands.kt
index adb8c472a..ec00f5891 100644
--- a/music/commands/src/main/kotlin/dev/schlaubi/mikmusic/commands/Commands.kt
+++ b/music/commands/src/main/kotlin/dev/schlaubi/mikmusic/commands/Commands.kt
@@ -1,6 +1,5 @@
 package dev.schlaubi.mikmusic.commands
 
-import dev.schlaubi.mikmusic.core.Config
 import dev.schlaubi.mikmusic.core.MusicModule
 
 suspend fun MusicModule.commands() {
@@ -19,8 +18,4 @@ suspend fun MusicModule.commands() {
     clearCommand()
     fixCommand()
     nextCommand()
-
-    if (Config.HAPPI_KEY != null) {
-        lyricsCommand()
-    }
 }
diff --git a/music/commands/src/main/kotlin/dev/schlaubi/mikmusic/commands/LycricsCommand.kt b/music/commands/src/main/kotlin/dev/schlaubi/mikmusic/commands/LycricsCommand.kt
deleted file mode 100644
index d621d9b13..000000000
--- a/music/commands/src/main/kotlin/dev/schlaubi/mikmusic/commands/LycricsCommand.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-package dev.schlaubi.mikmusic.commands
-
-import com.kotlindiscord.kord.extensions.commands.Arguments
-import com.kotlindiscord.kord.extensions.commands.converters.impl.optionalString
-import com.kotlindiscord.kord.extensions.extensions.publicSlashCommand
-import dev.schlaubi.mikbot.plugin.api.util.forList
-import dev.schlaubi.mikmusic.checks.musicQuizAntiCheat
-import dev.schlaubi.mikmusic.core.MusicModule
-import dev.schlaubi.mikmusic.util.fetchLyrics
-import dev.schlaubi.mikmusic.util.searchHappiSong
-import dev.schlaubi.stdx.core.paginate
-
-class LyricsArguments : Arguments() {
-    val name by optionalString {
-        name = "song_name"
-        description = "commands.lyrics.arguments.song_name.description"
-    }
-}
-
-suspend fun MusicModule.lyricsCommand() = publicSlashCommand(::LyricsArguments) {
-    name = "lyrics"
-    description = "commands.lyrics.description"
-
-    check {
-        musicQuizAntiCheat(this@lyricsCommand)
-    }
-
-    action {
-        val query = arguments.name ?: player.playingTrack?.info?.title
-            ?.replace("\\(.+(?!\\))".toRegex(), "") // replace anything in brackets like (official music video)
-
-        if (query == null) {
-            respond {
-                content = translate("command.lyrics.no_song_playing")
-            }
-
-            return@action
-        }
-        val trackResponse = searchHappiSong(query)
-        if (trackResponse.length != 1) {
-            respond {
-                content = translate("command.lyrics.no_lyrics")
-            }
-            return@action
-        }
-        val track = trackResponse.result!!.first()
-        val lyrics = track.fetchLyrics().result!!
-
-        val lines = lyrics.lyrics.lines()
-        val paged = lines.paginate(2000)
-
-        editingPaginator {
-            forList(user, paged, { it }, { _, _ -> track.track }) {
-                footer {
-                    text = "© ${lyrics.copyrightLabel ?: ""} | ${lyrics.copyrightNotice}"
-                }
-            }
-        }.send()
-    }
-}
diff --git a/music/lyrics/build.gradle.kts b/music/lyrics/build.gradle.kts
new file mode 100644
index 000000000..fd00e1689
--- /dev/null
+++ b/music/lyrics/build.gradle.kts
@@ -0,0 +1,37 @@
+import dev.schlaubi.mikbot.gradle.GenerateDefaultTranslationBundleTask
+import java.util.*
+
+plugins {
+    `mikbot-module`
+    com.google.devtools.ksp
+    dev.schlaubi.mikbot.`gradle-plugin`
+    alias(libs.plugins.kotlinx.serialization)
+}
+
+dependencies {
+    plugin(projects.music.player)
+    plugin(projects.core.ktor)
+    ktorDependency(libs.ktor.server.websockets)
+    ktorDependency(libs.ktor.server.cors)
+}
+
+mikbotPlugin {
+    pluginId = "music-lyrics"
+    description = "Plugin providing lyrics for the music plugin"
+}
+
+fun DependencyHandlerScope.ktorDependency(dependency: ProviderConvertible<*>) = ktorDependency(dependency.asProvider())
+fun DependencyHandlerScope.ktorDependency(dependency: Provider<*>) = implementation(dependency) {
+    exclude(module = "ktor-server-core")
+}
+
+
+tasks {
+    val generateDefaultResourceBundle by registering(GenerateDefaultTranslationBundleTask::class) {
+        defaultLocale = Locale("en", "GB")
+    }
+
+    classes {
+        dependsOn(generateDefaultResourceBundle)
+    }
+}
diff --git a/music/lyrics/src/main/kotlin/APIServer.kt b/music/lyrics/src/main/kotlin/APIServer.kt
new file mode 100644
index 000000000..7312dd15f
--- /dev/null
+++ b/music/lyrics/src/main/kotlin/APIServer.kt
@@ -0,0 +1,165 @@
+package dev.schlaubi.mikmusic.lyrics
+
+import com.kotlindiscord.kord.extensions.ExtensibleBot
+import com.kotlindiscord.kord.extensions.koin.KordExKoinComponent
+import dev.kord.cache.api.query
+import dev.kord.common.annotation.KordExperimental
+import dev.kord.common.annotation.KordUnsafe
+import dev.kord.common.entity.Snowflake
+import dev.kord.core.Kord
+import dev.kord.core.cache.data.VoiceStateData
+import dev.kord.core.event.user.VoiceStateUpdateEvent
+import dev.kord.core.on
+import dev.schlaubi.lavakord.audio.TrackEndEvent
+import dev.schlaubi.lavakord.audio.TrackStartEvent
+import dev.schlaubi.lavakord.audio.on
+import dev.schlaubi.lavakord.audio.player.Player
+import dev.schlaubi.lavakord.plugins.lyrics.rest.requestLyrics
+import dev.schlaubi.lyrics.protocol.TimedLyrics
+import dev.schlaubi.mikbot.plugin.api.config.Environment
+import dev.schlaubi.mikbot.util_plugins.ktor.api.KtorExtensionPoint
+import dev.schlaubi.mikmusic.core.MusicModule
+import dev.schlaubi.mikmusic.lyrics.events.Event
+import dev.schlaubi.mikmusic.lyrics.events.NextTrackEvent
+import dev.schlaubi.mikmusic.lyrics.events.PlayerStateUpdateEvent
+import dev.schlaubi.mikmusic.lyrics.events.PlayerStoppedEvent
+import dev.schlaubi.mikmusic.player.MusicPlayer
+import io.ktor.http.*
+import io.ktor.serialization.kotlinx.*
+import io.ktor.server.application.*
+import io.ktor.server.plugins.*
+import io.ktor.server.plugins.cors.routing.*
+import io.ktor.server.plugins.statuspages.*
+import io.ktor.server.request.*
+import io.ktor.server.response.*
+import io.ktor.server.routing.*
+import io.ktor.server.websocket.*
+import io.ktor.util.*
+import io.ktor.websocket.*
+import kotlinx.coroutines.*
+import kotlinx.datetime.Clock
+import kotlinx.serialization.json.Json
+import org.koin.core.component.inject
+import org.pf4j.Extension
+import kotlin.collections.set
+import kotlin.time.Duration.Companion.seconds
+import dev.schlaubi.mikbot.plugin.api.config.Config as BotConfig
+
+private val PLAYER = AttributeKey<MusicPlayer>("MUSIC_PLAYER")
+private val authKeys = mutableMapOf<String, Snowflake>()
+
+fun requestToken(userId: Snowflake): String {
+    val key = generateNonce()
+    authKeys[key] = userId
+    return key
+}
+
+@Extension
+class APIServer : KtorExtensionPoint, KordExKoinComponent {
+
+    private val bot by inject<ExtensibleBot>()
+    private val musicModule by lazy { bot.findExtension<MusicModule>()!! }
+
+    private val ApplicationCall.userId: Snowflake
+        get() {
+            val header = request.authorization() ?: parameters["api_key"] ?: unauthorized()
+            return authKeys[header] ?: unauthorized()
+        }
+
+    @OptIn(KordUnsafe::class, KordExperimental::class)
+    private suspend fun Snowflake.findPlayer(): MusicPlayer {
+        val voiceState = bot.kordRef.findVoiceState(this) ?: notFound()
+        val player = musicModule.getMusicPlayer(bot.kordRef.unsafe.guild(voiceState.guildId))
+
+        return player.takeIf { it.playingTrack != null } ?: notFound()
+    }
+
+    override fun Application.apply() {
+        if (pluginOrNull(WebSockets) == null) {
+            install(WebSockets) {
+                contentConverter = KotlinxWebsocketSerializationConverter(Json)
+            }
+        }
+        if (pluginOrNull(CORS) == null && BotConfig.ENVIRONMENT == Environment.DEVELOPMENT) {
+            install(CORS) {
+                allowMethod(HttpMethod.Options)
+                allowMethod(HttpMethod.Put)
+                allowMethod(HttpMethod.Delete)
+                allowMethod(HttpMethod.Patch)
+                allowHeader(HttpHeaders.Authorization)
+                anyHost()
+            }
+        }
+
+        routing {
+            route("lyrics") {
+                get("current") {
+                    val player = call.userId.findPlayer()
+
+                    call.respond(player.player.requestLyrics().takeIf { it is TimedLyrics } ?: notFound())
+                }
+
+                route("events") {
+                    intercept(ApplicationCallPipeline.Plugins) {
+                        call.attributes.put(PLAYER, call.userId.findPlayer())
+                        proceed()
+                    }
+
+                    webSocket {
+                        val player = call.attributes[PLAYER].player
+                        val listenerScope = this
+                        launch {
+                            var state = player.toState()
+                            while (isActive) {
+                                val newState = player.toState()
+                                if (newState != state && state.playing) {
+                                    state = newState
+                                    sendSerialized<Event>(newState)
+                                }
+
+                                delay(1.seconds)
+                            }
+                        }
+
+                        player.on<TrackEndEvent>(listenerScope) {
+                            if (!reason.mayStartNext) {
+                                sendSerialized<Event>(PlayerStoppedEvent)
+                            }
+                        }
+                        player.on<TrackStartEvent>(listenerScope) {
+                            sendSerialized<Event>(NextTrackEvent(player.position))
+                        }
+                        bot.kordRef.on<VoiceStateUpdateEvent>(listenerScope) {
+                            if (state.userId == call.userId && state.channelId == null && old?.channelId != null) {
+                                close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "Left voice channel"))
+                            }
+                        }
+
+                        // Wait for connection to die or websocket getting closed otherwise
+                        awaitCancellation()
+                    }
+                }
+            }
+        }
+    }
+
+    override fun StatusPagesConfig.apply() {
+        exception<UnauthorizedException> { call, _ ->
+            call.respond(HttpStatusCode.Unauthorized)
+        }
+    }
+}
+
+suspend fun Kord.findVoiceState(userId: Snowflake): VoiceStateData? {
+    return cache.query<VoiceStateData> {
+        VoiceStateData::channelId ne null
+        VoiceStateData::userId eq userId
+    }.singleOrNull()
+}
+
+private class UnauthorizedException : RuntimeException()
+
+private fun unauthorized(): Nothing = throw UnauthorizedException()
+private fun notFound(): Nothing = throw NotFoundException()
+
+private fun Player.toState() = PlayerStateUpdateEvent(!paused, position, Clock.System.now())
diff --git a/music/lyrics/src/main/kotlin/Config.kt b/music/lyrics/src/main/kotlin/Config.kt
new file mode 100644
index 000000000..0f3a7189f
--- /dev/null
+++ b/music/lyrics/src/main/kotlin/Config.kt
@@ -0,0 +1,7 @@
+package dev.schlaubi.mikmusic.lyrics
+
+import dev.schlaubi.mikbot.plugin.api.EnvironmentConfig
+
+object Config : EnvironmentConfig() {
+    val LYRICS_WEB_URL by getEnv("http://localhost:3000")
+}
diff --git a/music/lyrics/src/main/kotlin/KaraokeCommand.kt b/music/lyrics/src/main/kotlin/KaraokeCommand.kt
new file mode 100644
index 000000000..e3725c3bc
--- /dev/null
+++ b/music/lyrics/src/main/kotlin/KaraokeCommand.kt
@@ -0,0 +1,34 @@
+package dev.schlaubi.mikmusic.lyrics
+
+import com.kotlindiscord.kord.extensions.extensions.Extension
+import com.kotlindiscord.kord.extensions.extensions.ephemeralSlashCommand
+import dev.schlaubi.lavakord.plugins.lyrics.rest.requestLyrics
+import dev.schlaubi.lyrics.protocol.TimedLyrics
+import dev.schlaubi.mikbot.plugin.api.util.discordError
+import dev.schlaubi.mikmusic.checks.musicQuizAntiCheat
+import dev.schlaubi.mikmusic.util.musicModule
+
+suspend fun Extension.karaokeCommand() = ephemeralSlashCommand {
+    name = "karaoke"
+    description = "commands.karaoke.description"
+
+    check {
+        musicQuizAntiCheat(musicModule)
+    }
+
+    action {
+        val player = with(musicModule) { player }
+
+        val lyrics = runCatching { player.requestLyrics() }.getOrNull()
+
+        if (lyrics !is TimedLyrics) {
+            discordError(translate("commands.karaoke.not_available"))
+        }
+
+        val token = requestToken(user.id)
+
+        respond {
+            content = translate("commands.karaoke.success", arrayOf("${Config.LYRICS_WEB_URL}?apiKey=$token"))
+        }
+    }
+}
diff --git a/music/lyrics/src/main/kotlin/LyricsCommand.kt b/music/lyrics/src/main/kotlin/LyricsCommand.kt
new file mode 100644
index 000000000..3a7624842
--- /dev/null
+++ b/music/lyrics/src/main/kotlin/LyricsCommand.kt
@@ -0,0 +1,74 @@
+package dev.schlaubi.mikmusic.lyrics
+
+import com.kotlindiscord.kord.extensions.commands.Arguments
+import com.kotlindiscord.kord.extensions.commands.converters.impl.optionalString
+import com.kotlindiscord.kord.extensions.extensions.Extension
+import com.kotlindiscord.kord.extensions.extensions.publicSlashCommand
+import dev.schlaubi.lavakord.plugins.lyrics.rest.requestLyrics
+import dev.schlaubi.lavakord.plugins.lyrics.rest.searchLyrics
+import dev.schlaubi.lyrics.protocol.TimedLyrics
+import dev.schlaubi.mikbot.plugin.api.util.discordError
+import dev.schlaubi.mikbot.plugin.api.util.forList
+import dev.schlaubi.mikmusic.checks.musicQuizAntiCheat
+import dev.schlaubi.mikmusic.util.musicModule
+import dev.schlaubi.stdx.core.paginate
+
+class LyricsArguments : Arguments() {
+    val name by optionalString {
+        name = "song_name"
+        description = "commands.lyrics.arguments.song_name.description"
+    }
+}
+
+suspend fun Extension.lyricsCommand() = publicSlashCommand(::LyricsArguments) {
+    name = "lyrics"
+    description = "commands.lyrics.description"
+
+    check {
+        musicQuizAntiCheat(musicModule)
+    }
+
+    action {
+        val player = with(musicModule) { player }
+        val link = with(musicModule) { link }
+
+        val query = arguments.name ?: player.playingTrack?.info?.title
+            ?.replace("\\(.+(?!\\))".toRegex(), "") // replace anything in brackets like (official music video)
+
+        if (query == null) {
+            respond {
+                content = translate("command.lyrics.no_song_playing")
+            }
+
+            return@action
+        }
+        val lyrics = if (player.playingTrack != null) {
+            player.requestLyrics()
+        } else {
+            val (videoId) = link.node.searchLyrics(query).firstOrNull()
+                ?: discordError(translate("command.lyrics.no_lyrics"))
+            link.node.requestLyrics(videoId)
+        }
+
+        val lines = if (lyrics is TimedLyrics) {
+            lyrics.lines.map {
+                if (player.position in it.range) {
+                    "**__${it.line}__**"
+                } else {
+                    it.line
+                }
+            }
+        } else {
+            lyrics.text.lines()
+        }
+        val paged = lines.paginate(2000)
+
+        editingPaginator {
+            forList(user, paged, { it }, { _, _ -> lyrics.track.title }) {
+                footer {
+                    text = translate("command.lyrics.source", arrayOf(lyrics.source))
+                }
+            }
+        }.send()
+    }
+}
diff --git a/music/lyrics/src/main/kotlin/LyricsPlugin.kt b/music/lyrics/src/main/kotlin/LyricsPlugin.kt
new file mode 100644
index 000000000..362b952f6
--- /dev/null
+++ b/music/lyrics/src/main/kotlin/LyricsPlugin.kt
@@ -0,0 +1,24 @@
+package dev.schlaubi.mikmusic.lyrics
+
+import com.kotlindiscord.kord.extensions.builders.ExtensibleBotBuilder
+import dev.schlaubi.mikbot.plugin.api.Plugin
+import dev.schlaubi.mikbot.plugin.api.PluginContext
+import dev.schlaubi.mikbot.plugin.api.PluginMain
+import dev.schlaubi.mikbot.plugin.api.module.MikBotModule
+
+@PluginMain
+class LyricsPlugin(context: PluginContext) : Plugin(context) {
+    override fun ExtensibleBotBuilder.ExtensionsBuilder.addExtensions() {
+        add(::LyricsModule)
+    }
+}
+
+class LyricsModule(context: PluginContext) : MikBotModule(context) {
+    override val name: String = "lyrics"
+    override val bundle: String = "lyrics"
+
+    override suspend fun setup() {
+        lyricsCommand()
+        karaokeCommand()
+    }
+}
diff --git a/music/lyrics/src/main/kotlin/events/Events.kt b/music/lyrics/src/main/kotlin/events/Events.kt
new file mode 100644
index 000000000..34e81a3d6
--- /dev/null
+++ b/music/lyrics/src/main/kotlin/events/Events.kt
@@ -0,0 +1,46 @@
+package dev.schlaubi.mikmusic.lyrics.events
+
+import kotlinx.datetime.Instant
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonClassDiscriminator
+
+@OptIn(ExperimentalSerializationApi::class)
+@Serializable
+@JsonClassDiscriminator("type")
+sealed interface Event
+
+@SerialName("player_update")
+@Serializable
+data class PlayerStateUpdateEvent(
+    val playing: Boolean,
+    val position: Long,
+    val timestamp: Instant
+) : Event {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as PlayerStateUpdateEvent
+
+        if (playing != other.playing) return false
+        if (position != other.position) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = playing.hashCode()
+        result = 31 * result + position.hashCode()
+        return result
+    }
+}
+
+@SerialName("player_stopped")
+@Serializable
+data object PlayerStoppedEvent : Event
+
+@SerialName("next_track")
+@Serializable
+data class NextTrackEvent(val startPosition: Long) : Event
diff --git a/music/lyrics/src/main/resources/translations/lyrics/strings_de_DE.properties b/music/lyrics/src/main/resources/translations/lyrics/strings_de_DE.properties
new file mode 100644
index 000000000..8781a4061
--- /dev/null
+++ b/music/lyrics/src/main/resources/translations/lyrics/strings_de_DE.properties
@@ -0,0 +1,4 @@
+command.lyrics.source=Quelle: {0}
+commands.karaoke.description=Lässt dich eine Karaoke party haben
+commands.karaoke.not_available=Wir haben keine Karaoke fähigen Songtextre für dieses Lied :(
+commands.karaoke.success=Bitte öffne [diese Website]({0}) um Karaoke spaß zu haben
diff --git a/music/lyrics/src/main/resources/translations/lyrics/strings_en_GB.properties b/music/lyrics/src/main/resources/translations/lyrics/strings_en_GB.properties
new file mode 100644
index 000000000..175d51829
--- /dev/null
+++ b/music/lyrics/src/main/resources/translations/lyrics/strings_en_GB.properties
@@ -0,0 +1,4 @@
+command.lyrics.source=Source: {0}
+commands.karaoke.description=Allows having a karaoke party
+commands.karaoke.not_available=We do not have karaoke-capable lyrics for this track :(
+commands.karaoke.success=Please open [this website]({0}) to have karaoke fun
diff --git a/music/player/build.gradle.kts b/music/player/build.gradle.kts
index bb2a5d7da..81b4d622f 100644
--- a/music/player/build.gradle.kts
+++ b/music/player/build.gradle.kts
@@ -16,6 +16,7 @@ dependencies {
     api(libs.lavakord.sponsorblock)
     api(libs.lavakord.lavsrc)
     api(libs.lavakord.lavasearch)
+    api(libs.lavakord.lyrics)
 
     // Plattform support
     implementation(libs.google.apis.youtube)
diff --git a/music/player/src/main/kotlin/dev/schlaubi/mikmusic/autocomplete/AutoCompleteArgument.kt b/music/player/src/main/kotlin/dev/schlaubi/mikmusic/autocomplete/AutoCompleteArgument.kt
index 4da4f28b5..37d7d072b 100644
--- a/music/player/src/main/kotlin/dev/schlaubi/mikmusic/autocomplete/AutoCompleteArgument.kt
+++ b/music/player/src/main/kotlin/dev/schlaubi/mikmusic/autocomplete/AutoCompleteArgument.kt
@@ -36,7 +36,7 @@ fun Arguments.autoCompletedYouTubeQuery(description: String): SingleConverter<St
                     choice(text, text)
                 }
                 result.artists.take(5).forEach { playlist ->
-                    choice("${Emojis.man} ${playlist.info.name}", playlist.lavaSrcInfo.url)
+                    choice("${Emojis.man} ${playlist.info.name}", playlist.lavaSrcInfo.url.toString())
                 }
                 result.tracks.take(5).forEach { track ->
                     val uri = track.info.uri ?: return@forEach
@@ -44,10 +44,10 @@ fun Arguments.autoCompletedYouTubeQuery(description: String): SingleConverter<St
                 }
                 result.albums.take(5).forEach { playlist ->
                     val albumInfo = playlist.lavaSrcInfo
-                    choice("${Emojis.cd} ${albumInfo.author} - ${playlist.info.name}", albumInfo.url)
+                    choice("${Emojis.cd} ${albumInfo.author} - ${playlist.info.name}", albumInfo.url.toString())
                 }
                 result.playlists.take(5).forEach { playlist ->
-                    choice("${Emojis.scroll} ${playlist.info.name}", playlist.lavaSrcInfo.url)
+                    choice("${Emojis.scroll} ${playlist.info.name}", playlist.lavaSrcInfo.url.toString())
                 }
             }
         }
diff --git a/music/player/src/main/kotlin/dev/schlaubi/mikmusic/player/queue/QueueResponse.kt b/music/player/src/main/kotlin/dev/schlaubi/mikmusic/player/queue/QueueResponse.kt
index 61e23f0a6..2e4056bff 100644
--- a/music/player/src/main/kotlin/dev/schlaubi/mikmusic/player/queue/QueueResponse.kt
+++ b/music/player/src/main/kotlin/dev/schlaubi/mikmusic/player/queue/QueueResponse.kt
@@ -44,7 +44,7 @@ class Playlist(private val playlist: Playlist, tracks: List<Track>) : QueueSearc
         if (lavaSrcInfo != null) {
             url = lavaSrcInfo.url
             thumbnail {
-                url = lavaSrcInfo.artworkUrl
+                url = lavaSrcInfo.artworkUrl.toString()
             }
 
             author {
diff --git a/runtime/plugins.txt b/runtime/plugins.txt
index 6e050c8b3..364f366d8 100644
--- a/runtime/plugins.txt
+++ b/runtime/plugins.txt
@@ -1,4 +1,4 @@
-:core:gdpr
-:core:redeploy-hook
+:core:ktor
 :music:player
 :music:commands
+:music:lyrics
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 52a912810..df88ae578 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -28,6 +28,7 @@ if (System.getenv("BUILD_PLUGIN_CI")?.toBoolean() != true) {
         ":music",
         "music:player",
         "music:commands",
+        "music:lyrics",
         "clients:discord-oauth",
         "clients:haste-client",
         "clients:image-color-client",