Skip to content

Commit

Permalink
Funny song lookup command using odesli.co.
Browse files Browse the repository at this point in the history
  • Loading branch information
CephalonCosmic committed Feb 8, 2025
1 parent 2f12497 commit 677aeec
Show file tree
Hide file tree
Showing 4 changed files with 237 additions and 0 deletions.
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ kotlinx-serialization = "1.7.3"
ktoml = "0.5.2"
# Ktor
ktor = "3.0.1"
# fleeksoft/ksoup
ksoup = "0.2.2"
# Lavalink
lavalink-client = "3.1.0"
# Logback
Expand Down Expand Up @@ -69,6 +71,8 @@ ktor-server-cio = { module = "io.ktor:ktor-server-cio-jvm", version.ref = "ktor"
ktor-server-config-yaml = { module = "io.ktor:ktor-server-config-yaml", version.ref = "ktor" }
ktor-server-content-negogiation = { module = "io.ktor:ktor-server-content-negotiation", version.ref = "ktor" }
ktor-server-core = { module = "io.ktor:ktor-server-core-jvm", version.ref = "ktor" }
# Ksoup
ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" }
# Lavalink
lavalink-client = { module = "dev.arbjerg:lavalink-client", version.ref = "lavalink-client" }
# Logback
Expand Down
12 changes: 12 additions & 0 deletions yiski3/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,23 @@ repositories {

dependencies {
implementation(rootProject.libs.github.api)
implementation("io.ktor:ktor-client-core-jvm:3.0.1")
implementation("io.ktor:ktor-client-apache:3.0.1")
implementation("io.ktor:ktor-client-cio-jvm:3.0.1")
shade(rootProject.libs.github.api)

implementation(rootProject.libs.okhttp)
shade(rootProject.libs.okhttp)

implementation(rootProject.libs.ktor.client.core)
shade(rootProject.libs.ktor.client.core)

implementation(rootProject.libs.ktor.client.cio.jvm)
shade(rootProject.libs.ktor.client.cio.jvm)

implementation(rootProject.libs.ksoup)
shade(rootProject.libs.ksoup)

implementation(files("libs/osu4j-2.0.1.jar"))
shade(files("libs/osu4j-2.0.1.jar"))
}
Expand Down
156 changes: 156 additions & 0 deletions yiski3/src/main/kotlin/one/devos/yiski3/commands/Song.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package one.devos.yiski3.commands

import com.fleeksoft.ksoup.Ksoup
import com.fleeksoft.ksoup.nodes.Document
import com.fleeksoft.ksoup.select.Evaluator
import dev.minn.jda.ktx.coroutines.await
import dev.minn.jda.ktx.interactions.components.link
import dev.minn.jda.ktx.interactions.components.row
import dev.minn.jda.ktx.messages.Embed
import dev.minn.jda.ktx.messages.editMessage
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import one.devos.yiski.common.annotations.YiskiModule
import one.devos.yiski3.utils.KotlinxGenericMapSerializer
import one.devos.yiski3.utils.KotlinxGenericMapSerializer.toJsonElement
import xyz.artrinix.aviation.command.slash.SlashContext
import xyz.artrinix.aviation.command.slash.annotations.SlashCommand
import xyz.artrinix.aviation.entities.Scaffold
import java.net.URL
import java.time.ZoneId
import java.util.*

// @TODO Implement rate limits & caching results to the database to avoid API limits.
@YiskiModule
class Song : Scaffold {
private val userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"

@Serializable
data class ResolveStructure(
val provider: String,
val type: String,
val id: String,
)

@SlashCommand(name = "song", description = "Look up a song for other platforms")
suspend fun song(ctx: SlashContext, url: URL) {
val reply = ctx.interaction.deferReply().await();

val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}

val resolve = client.get("https://api.odesli.co/resolve") {
url {
parameters.append("url", url.toString().encodeURLParameter())
}
headers {
append(HttpHeaders.UserAgent, userAgent)
}
}

val resolveBody = resolve.body<ResolveStructure>()

val provider = when(val p = resolveBody.provider) {
"soundcloud" -> "sc"
"audiomack" -> "am"
else -> p.first().toString()
}

val request = client.get("https://${resolveBody.type}.link") {
url {
appendPathSegments(provider, resolveBody.id)
}
headers {
append(HttpHeaders.UserAgent, userAgent)
}
}

val doc: Document = Ksoup.parse(html = request.bodyAsText())
val nextData = doc.select(Evaluator.Id("__NEXT_DATA__")).first() ?: error("could not get the data from the page")

val data = Json.decodeFromString(KotlinxGenericMapSerializer, nextData.data())
val pageData = data["props"]
.toJsonElement()
.jsonObject["pageProps"]!!
.jsonObject["pageData"]!!
.jsonObject

val sections = pageData["sections"]!!
.jsonArray

val songData = pageData["entityData"]!!
.jsonObject

val links = sections[1].jsonObject["links"]!!.jsonArray

reply
.editMessage(
content = "Here you go, ${ctx.interaction.member!!.asMention}!",
embeds = listOf(
Embed {
val type = songData["type"]?.jsonPrimitive?.content?.replaceFirstChar {
if (it.isLowerCase()) it.titlecase(
Locale.getDefault()
) else it.toString()
}

author("${songData["artistName"]!!.jsonPrimitive.content} | $type", pageData["pageUrl"]!!.jsonPrimitive.content)
title = songData["title"]!!.jsonPrimitive.content
thumbnail = songData["thumbnailUrl"]!!.jsonPrimitive.content
if (songData["releaseDate"] !== null) field {
val releaseEpoch = songData["releaseDate"]!!.jsonObject.let {
Calendar.Builder()
.setDate(
it["year"]!!.jsonPrimitive.content.toInt(),
it["month"]!!.jsonPrimitive.content.toInt(),
it["day"]!!.jsonPrimitive.content.toInt()
)
.setTimeZone(TimeZone.getTimeZone(ZoneId.of("UTC")))
.build()
.toInstant().epochSecond
}
name = "Released on"
value = "<t:${releaseEpoch}:D>"
inline = true
}
if (songData["genre"] != null) field {
name = "Genre"
value = songData["genre"]!!.jsonPrimitive.content
inline = true
}
if (type == "Album") field {
name = "Tracks"
value = songData["numTracks"]!!.jsonPrimitive.content
inline = true
}
footer("Powered by odesli.co")
}
),
components = links.map { it.jsonObject }.filter { it["url"] != null }.chunked(5).map { group ->
row(
*group.map { provider ->
link(
provider["url"]!!.jsonPrimitive.content,
provider["displayName"]!!.jsonPrimitive.content
)
}.toTypedArray()
)
}
)
.await()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package one.devos.yiski3.utils

import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
import kotlinx.serialization.serializer

object KotlinxGenericMapSerializer : KSerializer<Map<String, Any?>> {

override val descriptor: SerialDescriptor = buildClassSerialDescriptor("GenericMap")

override fun serialize(encoder: Encoder, value: Map<String, Any?>) {
val jsonObject = JsonObject(value.mapValues { it.value.toJsonElement()})
val jsonObjectSerializer = encoder.serializersModule.serializer<JsonObject>()
jsonObjectSerializer.serialize(encoder, jsonObject)
}

override fun deserialize(decoder: Decoder): Map<String, Any?> {
val jsonDecoder = decoder as? JsonDecoder ?: throw SerializationException("Can only deserialize Json content to generic Map")
val root = jsonDecoder.decodeJsonElement()
return if (root is JsonObject) root.toMap() else throw SerializationException("Cannot deserialize Json content to generic Map")
}

fun Any?.toJsonElement(): JsonElement = when(this) {
null -> JsonNull
is String -> JsonPrimitive(this)
is Number -> JsonPrimitive(this)
is Boolean -> JsonPrimitive(this)
is Map<*, *> -> toJsonObject()
is Iterable<*> -> toJsonArray()
else -> throw SerializationException("Cannot serialize value type $this")
}

private fun Map<*,*>.toJsonObject(): JsonObject = JsonObject(this.entries.associate { it.key.toString() to it.value.toJsonElement() })

private fun Iterable<*>.toJsonArray(): JsonArray = JsonArray(this.map { it.toJsonElement() })

private fun JsonElement.toAnyNullableValue(): Any? = when (this) {
is JsonPrimitive -> toScalarOrNull()
is JsonObject -> toMap()
is JsonArray -> toList()
}

private fun JsonObject.toMap(): Map<String, Any?> = entries.associate {
when (val jsonElement = it.value) {
is JsonPrimitive -> it.key to jsonElement.toScalarOrNull()
is JsonObject -> it.key to jsonElement.toMap()
is JsonArray -> it.key to jsonElement.toAnyNullableValueList()
}
}

private fun JsonPrimitive.toScalarOrNull(): Any? = when {
this is JsonNull -> null
this.isString -> this.content
else -> listOfNotNull(booleanOrNull, longOrNull, doubleOrNull).firstOrNull()
}

private fun JsonArray.toAnyNullableValueList(): List<Any?> = this.map {
it.toAnyNullableValue()
}
}

0 comments on commit 677aeec

Please sign in to comment.