mirror of
https://github.com/dalbodeule/chibot-chzzk-bot.git
synced 2025-06-09 07:18:22 +00:00
commit
240503a4d5
3
.idea/.gitignore
generated
vendored
3
.idea/.gitignore
generated
vendored
@ -6,4 +6,5 @@
|
|||||||
# Datasource local storage ignored files
|
# Datasource local storage ignored files
|
||||||
/dataSources/
|
/dataSources/
|
||||||
/dataSources.local.xml
|
/dataSources.local.xml
|
||||||
discord.xml
|
discord.xml
|
||||||
|
inspectionProfiles/Project_Default.xml
|
@ -4,10 +4,12 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.java.KoinJavaComponent.inject
|
import org.koin.java.KoinJavaComponent.inject
|
||||||
|
import space.mori.chzzk_bot.chatbot.discord.Discord.Companion.bot
|
||||||
import space.mori.chzzk_bot.common.events.*
|
import space.mori.chzzk_bot.common.events.*
|
||||||
import space.mori.chzzk_bot.common.models.User
|
import space.mori.chzzk_bot.common.models.User
|
||||||
import space.mori.chzzk_bot.common.services.*
|
import space.mori.chzzk_bot.common.services.*
|
||||||
import space.mori.chzzk_bot.common.utils.getFollowDate
|
import space.mori.chzzk_bot.common.utils.getFollowDate
|
||||||
|
import space.mori.chzzk_bot.common.utils.getRandomString
|
||||||
import space.mori.chzzk_bot.common.utils.getUptime
|
import space.mori.chzzk_bot.common.utils.getUptime
|
||||||
import space.mori.chzzk_bot.common.utils.getYoutubeVideo
|
import space.mori.chzzk_bot.common.utils.getYoutubeVideo
|
||||||
import xyz.r2turntrue.chzzk4j.chat.ChatMessage
|
import xyz.r2turntrue.chzzk4j.chat.ChatMessage
|
||||||
@ -15,6 +17,7 @@ import xyz.r2turntrue.chzzk4j.chat.ChzzkChat
|
|||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.temporal.ChronoUnit
|
import java.time.temporal.ChronoUnit
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
|
||||||
class MessageHandler(
|
class MessageHandler(
|
||||||
@ -57,7 +60,8 @@ class MessageHandler(
|
|||||||
"!명령어수정" to this::manageUpdateCommand,
|
"!명령어수정" to this::manageUpdateCommand,
|
||||||
"!시간" to this::timerCommand,
|
"!시간" to this::timerCommand,
|
||||||
"!노래추가" to this::songAddCommand,
|
"!노래추가" to this::songAddCommand,
|
||||||
"!노래목록" to this::songListCommand
|
"!노래목록" to this::songListCommand,
|
||||||
|
"!노래시작" to this::songStartCommand
|
||||||
)
|
)
|
||||||
|
|
||||||
manageCommands.forEach { (commandName, command) ->
|
manageCommands.forEach { (commandName, command) ->
|
||||||
@ -248,6 +252,26 @@ class MessageHandler(
|
|||||||
listener.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}")
|
listener.sendChat("리스트는 여기입니다. https://nabot.mori.space/songs/${user.token}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun songStartCommand(msg: ChatMessage, user: User) {
|
||||||
|
if (msg.profile?.userRoleCode == "common_user") {
|
||||||
|
listener.sendChat("매니저만 이 명령어를 사용할 수 있습니다.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val session = "${UUID.randomUUID()}${UUID.randomUUID()}".replace("-", "")
|
||||||
|
val password = getRandomString(8)
|
||||||
|
|
||||||
|
SongConfigService.updateSession(user, session, password)
|
||||||
|
|
||||||
|
bot.getUserById(user.token)?.let { it ->
|
||||||
|
val channel = it.openPrivateChannel()
|
||||||
|
channel.onSuccess { privateChannel ->
|
||||||
|
privateChannel.sendMessage("여기로 접속해주세요! https://nabot,mori.space/songlist/${session}.\n인증번호는 ||$password|| 입니다.").queue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
internal fun handle(msg: ChatMessage, user: User) {
|
internal fun handle(msg: ChatMessage, user: User) {
|
||||||
if(msg.userId == ChzzkHandler.botUid) return
|
if(msg.userId == ChzzkHandler.botUid) return
|
||||||
|
|
||||||
|
@ -8,11 +8,19 @@ import org.jetbrains.exposed.sql.ReferenceOption
|
|||||||
|
|
||||||
object SongConfigs: IntIdTable("song_config") {
|
object SongConfigs: IntIdTable("song_config") {
|
||||||
val user = reference("user", Users, onDelete = ReferenceOption.CASCADE)
|
val user = reference("user", Users, onDelete = ReferenceOption.CASCADE)
|
||||||
val option = integer("option")
|
val token = varchar("token", 64).nullable()
|
||||||
|
val password = varchar("password", 8).nullable()
|
||||||
|
val streamerOnly = bool("streamer_only").default(false)
|
||||||
|
val queueLimit = integer("queue_limit").default(50)
|
||||||
|
val personalLimit = integer("personal_limit").default(5)
|
||||||
}
|
}
|
||||||
class SongConfig(id: EntityID<Int>) : IntEntity(id) {
|
class SongConfig(id: EntityID<Int>) : IntEntity(id) {
|
||||||
companion object : IntEntityClass<TimerConfig>(TimerConfigs)
|
companion object : IntEntityClass<SongConfig>(SongConfigs)
|
||||||
|
|
||||||
var user by User referencedOn TimerConfigs.user
|
var user by User referencedOn SongConfigs.user
|
||||||
var option by TimerConfigs.option
|
var token by SongConfigs.token
|
||||||
|
var password by SongConfigs.password
|
||||||
|
var streamerOnly by SongConfigs.streamerOnly
|
||||||
|
var queueLimit by SongConfigs.queueLimit
|
||||||
|
var personalLimit by SongConfigs.personalLimit
|
||||||
}
|
}
|
@ -0,0 +1,87 @@
|
|||||||
|
package space.mori.chzzk_bot.common.services
|
||||||
|
|
||||||
|
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
|
||||||
|
import org.jetbrains.exposed.sql.transactions.transaction
|
||||||
|
import space.mori.chzzk_bot.common.models.SongConfig
|
||||||
|
import space.mori.chzzk_bot.common.models.SongConfigs
|
||||||
|
import space.mori.chzzk_bot.common.models.User
|
||||||
|
|
||||||
|
object SongConfigService {
|
||||||
|
private fun initConfig(user: User): SongConfig {
|
||||||
|
return transaction {
|
||||||
|
SongConfig.new {
|
||||||
|
this.user = user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getConfig(user: User): SongConfig {
|
||||||
|
return transaction {
|
||||||
|
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
|
||||||
|
if (songConfig == null) {
|
||||||
|
songConfig = initConfig(user)
|
||||||
|
}
|
||||||
|
songConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getConfig(token: String): SongConfig? {
|
||||||
|
return transaction {
|
||||||
|
SongConfig.find(SongConfigs.token eq token).firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun getUserByToken(token: String): User? {
|
||||||
|
return transaction {
|
||||||
|
val songConfig = SongConfig.find(SongConfigs.token eq token).firstOrNull()
|
||||||
|
if(songConfig == null) null
|
||||||
|
else UserService.getUser(songConfig.user.discord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updatePersonalLimit(user: User, limit: Int): SongConfig {
|
||||||
|
return transaction {
|
||||||
|
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
|
||||||
|
if (songConfig == null) {
|
||||||
|
songConfig = initConfig(user)
|
||||||
|
}
|
||||||
|
songConfig.personalLimit = limit
|
||||||
|
songConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun updateQueueLimit(user: User, limit: Int): SongConfig {
|
||||||
|
return transaction {
|
||||||
|
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
|
||||||
|
if (songConfig == null) {
|
||||||
|
songConfig = initConfig(user)
|
||||||
|
}
|
||||||
|
songConfig.queueLimit = limit
|
||||||
|
songConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSession(user: User, token: String?, password: String?): SongConfig {
|
||||||
|
return transaction {
|
||||||
|
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
|
||||||
|
if (songConfig == null) {
|
||||||
|
songConfig = initConfig(user)
|
||||||
|
}
|
||||||
|
songConfig.token = token
|
||||||
|
songConfig.password = password
|
||||||
|
|
||||||
|
songConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateStreamerOnly(user: User, config: Boolean): SongConfig {
|
||||||
|
return transaction {
|
||||||
|
var songConfig = SongConfig.find(SongConfigs.user eq user.id).firstOrNull()
|
||||||
|
if (songConfig == null) {
|
||||||
|
songConfig = initConfig(user)
|
||||||
|
}
|
||||||
|
songConfig.streamerOnly = config
|
||||||
|
|
||||||
|
songConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -129,7 +129,7 @@ fun getFollowDate(chatID: String, userId: String) : IData<IFollowContent?> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getStreamInfo(userId: String) : IData<IStreamInfo?> {
|
fun getStreamInfo(userId: String) : IData<IStreamInfo?> {
|
||||||
val url = "https://api.chzzk.naver.com/service/v2/channels/${userId}/live-detail"
|
val url = "https://api.chzzk.naver.com/service/v3/channels/${userId}/live-detail"
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.build()
|
.build()
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
package space.mori.chzzk_bot.common.utils
|
||||||
|
|
||||||
|
fun getRandomString(length: Int): String {
|
||||||
|
val charPool = ('a'..'z') + ('0'..'9')
|
||||||
|
return (1..length)
|
||||||
|
.map { kotlin.random.Random.nextInt(0, charPool.size) }
|
||||||
|
.map(charPool::get)
|
||||||
|
.joinToString("")
|
||||||
|
}
|
@ -28,7 +28,6 @@ fun getYoutubeVideoId(url: String): String? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun parseDuration(duration: String): Int {
|
fun parseDuration(duration: String): Int {
|
||||||
println(duration)
|
|
||||||
val matchResult = durationRegex.find(duration)
|
val matchResult = durationRegex.find(duration)
|
||||||
val (hours, minutes, seconds) = matchResult?.destructured ?: return 0
|
val (hours, minutes, seconds) = matchResult?.destructured ?: return 0
|
||||||
|
|
||||||
@ -67,8 +66,6 @@ fun getYoutubeVideo(url: String): YoutubeVideo? {
|
|||||||
|
|
||||||
if (items == null || items.size() == 0) return null
|
if (items == null || items.size() == 0) return null
|
||||||
|
|
||||||
println(json)
|
|
||||||
|
|
||||||
val item = items[0].asJsonObject
|
val item = items[0].asJsonObject
|
||||||
val snippet = item.getAsJsonObject("snippet")
|
val snippet = item.getAsJsonObject("snippet")
|
||||||
val contentDetail = item.getAsJsonObject("contentDetails")
|
val contentDetail = item.getAsJsonObject("contentDetails")
|
||||||
|
@ -12,10 +12,7 @@ import io.ktor.server.plugins.swagger.*
|
|||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
import io.ktor.server.websocket.*
|
import io.ktor.server.websocket.*
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import space.mori.chzzk_bot.webserver.routes.apiRoutes
|
import space.mori.chzzk_bot.webserver.routes.*
|
||||||
import space.mori.chzzk_bot.webserver.routes.apiSongRoutes
|
|
||||||
import space.mori.chzzk_bot.webserver.routes.wsSongRoutes
|
|
||||||
import space.mori.chzzk_bot.webserver.routes.wsTimerRoutes
|
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
|
|
||||||
val server = embeddedServer(Netty, port = 8080) {
|
val server = embeddedServer(Netty, port = 8080) {
|
||||||
@ -42,6 +39,7 @@ val server = embeddedServer(Netty, port = 8080) {
|
|||||||
apiSongRoutes()
|
apiSongRoutes()
|
||||||
wsTimerRoutes()
|
wsTimerRoutes()
|
||||||
wsSongRoutes()
|
wsSongRoutes()
|
||||||
|
wsSongListRoutes()
|
||||||
swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") {
|
swaggerUI("swagger-ui/index.html", "openapi/documentation.yaml") {
|
||||||
options {
|
options {
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
@ -0,0 +1,167 @@
|
|||||||
|
package space.mori.chzzk_bot.webserver.routes
|
||||||
|
|
||||||
|
import io.ktor.server.routing.*
|
||||||
|
import io.ktor.server.websocket.*
|
||||||
|
import io.ktor.websocket.*
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.channels.ClosedReceiveChannelException
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koin.java.KoinJavaComponent.inject
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import space.mori.chzzk_bot.common.events.*
|
||||||
|
import space.mori.chzzk_bot.common.services.SongConfigService
|
||||||
|
import space.mori.chzzk_bot.common.services.SongListService
|
||||||
|
import space.mori.chzzk_bot.common.utils.getYoutubeVideo
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
|
||||||
|
fun Routing.wsSongListRoutes() {
|
||||||
|
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
|
||||||
|
val status = ConcurrentHashMap<String, SongType>()
|
||||||
|
val logger = LoggerFactory.getLogger(this.javaClass.name)
|
||||||
|
|
||||||
|
val dispatcher: CoroutinesEventBus by inject(CoroutinesEventBus::class.java)
|
||||||
|
|
||||||
|
fun addSession(uid: String, session: WebSocketServerSession) {
|
||||||
|
sessions.computeIfAbsent(uid) { ConcurrentLinkedQueue() }.add(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeSession(uid: String, session: WebSocketServerSession) {
|
||||||
|
sessions[uid]?.remove(session)
|
||||||
|
if(sessions[uid]?.isEmpty() == true) {
|
||||||
|
sessions.remove(uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
webSocket("/songlist/{sid}") {
|
||||||
|
val sid = call.parameters["sid"]
|
||||||
|
val pw = call.request.headers["X-Auth-Token"]
|
||||||
|
val session = sid?.let { SongConfigService.getConfig(it) }
|
||||||
|
val user = sid?.let {SongConfigService.getUserByToken(sid) }
|
||||||
|
if (sid == null) {
|
||||||
|
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID"))
|
||||||
|
return@webSocket
|
||||||
|
}
|
||||||
|
if (user == null || session == null || session.password != pw) {
|
||||||
|
close(CloseReason(CloseReason.Codes.CANNOT_ACCEPT, "Invalid SID"))
|
||||||
|
return@webSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
addSession(sid, this)
|
||||||
|
|
||||||
|
if(status[sid] == SongType.STREAM_OFF) {
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
sendSerialized(SongResponse(
|
||||||
|
SongType.STREAM_OFF.value,
|
||||||
|
user.token,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (frame in incoming) {
|
||||||
|
when(frame) {
|
||||||
|
is Frame.Text -> {
|
||||||
|
val data = receiveDeserialized<SongRequest>()
|
||||||
|
|
||||||
|
if(data.maxQueue != null && data.maxQueue > 0) SongConfigService.updateQueueLimit(user, data.maxQueue)
|
||||||
|
if(data.maxUserLimit != null && data.maxUserLimit > 0) SongConfigService.updatePersonalLimit(user, data.maxUserLimit)
|
||||||
|
if(data.isStreamerOnly != null) SongConfigService.updateStreamerOnly(user, data.isStreamerOnly)
|
||||||
|
|
||||||
|
if(data.url != null) {
|
||||||
|
val youtubeVideo = getYoutubeVideo(data.url)
|
||||||
|
|
||||||
|
dispatcher.post(
|
||||||
|
SongEvent(
|
||||||
|
user.token,
|
||||||
|
SongType.ADD,
|
||||||
|
user.token,
|
||||||
|
user.username,
|
||||||
|
youtubeVideo?.name,
|
||||||
|
youtubeVideo?.author,
|
||||||
|
youtubeVideo?.length
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if(data.remove != null && data.remove > 0) {
|
||||||
|
val songs = SongListService.getSong(user)
|
||||||
|
if(songs.size < data.remove) {
|
||||||
|
val song = songs[data.remove]
|
||||||
|
SongListService.deleteSong(user, song.uid, song.name)
|
||||||
|
|
||||||
|
dispatcher.post(
|
||||||
|
SongEvent(
|
||||||
|
user.token,
|
||||||
|
SongType.REMOVE,
|
||||||
|
user.token,
|
||||||
|
user.username,
|
||||||
|
song.name,
|
||||||
|
song.author,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Frame.Ping -> send(Frame.Pong(frame.data))
|
||||||
|
else -> {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e: ClosedReceiveChannelException) {
|
||||||
|
logger.error("Error in WebSocket: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
removeSession(sid, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatcher.subscribe(SongEvent::class) {
|
||||||
|
logger.debug("SongEvent: {} / {} {}", it.uid, it.type, it.name)
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
sessions[it.uid]?.forEach { ws ->
|
||||||
|
ws.sendSerialized(SongResponse(
|
||||||
|
it.type.value,
|
||||||
|
it.uid,
|
||||||
|
it.reqUid,
|
||||||
|
it.name,
|
||||||
|
it.author,
|
||||||
|
it.time
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dispatcher.subscribe(TimerEvent::class) {
|
||||||
|
if(it.type == TimerType.STREAM_OFF) {
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
sessions[it.uid]?.forEach { ws ->
|
||||||
|
ws.sendSerialized(SongResponse(
|
||||||
|
it.type.value,
|
||||||
|
it.uid,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SongRequest(
|
||||||
|
val type: Int,
|
||||||
|
val uid: String,
|
||||||
|
val url: String?,
|
||||||
|
val maxQueue: Int?,
|
||||||
|
val maxUserLimit: Int?,
|
||||||
|
val isStreamerOnly: Boolean?,
|
||||||
|
val remove: Int?
|
||||||
|
)
|
@ -17,7 +17,7 @@ import java.util.concurrent.ConcurrentLinkedQueue
|
|||||||
|
|
||||||
fun Routing.wsSongRoutes() {
|
fun Routing.wsSongRoutes() {
|
||||||
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
|
val sessions = ConcurrentHashMap<String, ConcurrentLinkedQueue<WebSocketServerSession>>()
|
||||||
val status = ConcurrentHashMap<String, TimerType>()
|
val status = ConcurrentHashMap<String, SongType>()
|
||||||
val logger = LoggerFactory.getLogger(this.javaClass.name)
|
val logger = LoggerFactory.getLogger(this.javaClass.name)
|
||||||
|
|
||||||
fun addSession(uid: String, session: WebSocketServerSession) {
|
fun addSession(uid: String, session: WebSocketServerSession) {
|
||||||
@ -45,7 +45,7 @@ fun Routing.wsSongRoutes() {
|
|||||||
|
|
||||||
addSession(uid, this)
|
addSession(uid, this)
|
||||||
|
|
||||||
if(status[uid] == TimerType.STREAM_OFF) {
|
if(status[uid] == SongType.STREAM_OFF) {
|
||||||
CoroutineScope(Dispatchers.Default).launch {
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
sendSerialized(SongResponse(
|
sendSerialized(SongResponse(
|
||||||
SongType.STREAM_OFF.value,
|
SongType.STREAM_OFF.value,
|
||||||
@ -94,6 +94,22 @@ fun Routing.wsSongRoutes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
dispatcher.subscribe(TimerEvent::class) {
|
||||||
|
if(it.type == TimerType.STREAM_OFF) {
|
||||||
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
sessions[it.uid]?.forEach { ws ->
|
||||||
|
ws.sendSerialized(SongResponse(
|
||||||
|
it.type.value,
|
||||||
|
it.uid,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
Loading…
x
Reference in New Issue
Block a user