Skip to content

Commit

Permalink
Add lyrics plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
DRSchlaubi committed Dec 21, 2023
1 parent 557b2ca commit 9ebf59d
Show file tree
Hide file tree
Showing 20 changed files with 418 additions and 77 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
Expand All @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion music/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
subprojects {
version = "3.4.1-SNAPSHOT"
version = "3.5.0-SNAPSHOT"
}
1 change: 0 additions & 1 deletion music/commands/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ tasks {
freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers"
}
}

}

mikbotPlugin {
Expand Down
Original file line number Diff line number Diff line change
@@ -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() {
Expand All @@ -19,8 +18,4 @@ suspend fun MusicModule.commands() {
clearCommand()
fixCommand()
nextCommand()

if (Config.HAPPI_KEY != null) {
lyricsCommand()
}
}

This file was deleted.

37 changes: 37 additions & 0 deletions music/lyrics/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
}
165 changes: 165 additions & 0 deletions music/lyrics/src/main/kotlin/APIServer.kt
Original file line number Diff line number Diff line change
@@ -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())
7 changes: 7 additions & 0 deletions music/lyrics/src/main/kotlin/Config.kt
Original file line number Diff line number Diff line change
@@ -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")
}
34 changes: 34 additions & 0 deletions music/lyrics/src/main/kotlin/KaraokeCommand.kt
Original file line number Diff line number Diff line change
@@ -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"))
}
}
}
Loading

0 comments on commit 9ebf59d

Please sign in to comment.