-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Funny song lookup command using odesli.co.
- Loading branch information
1 parent
2f12497
commit 677aeec
Showing
4 changed files
with
237 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
156 changes: 156 additions & 0 deletions
156
yiski3/src/main/kotlin/one/devos/yiski3/commands/Song.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} |
65 changes: 65 additions & 0 deletions
65
yiski3/src/main/kotlin/one/devos/yiski3/utils/KotlinxGenericMapSerializer.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} |