Add kugou as lyrics provider (#126)
This commit is contained in:
parent
a246f1f336
commit
3e00671122
16 changed files with 286 additions and 74 deletions
|
@ -93,7 +93,7 @@ dependencies {
|
||||||
kapt(libs.room.compiler)
|
kapt(libs.room.compiler)
|
||||||
|
|
||||||
implementation(projects.youtubeMusic)
|
implementation(projects.youtubeMusic)
|
||||||
implementation(projects.synchronizedLyrics)
|
implementation(projects.kugou)
|
||||||
|
|
||||||
coreLibraryDesugaring(libs.desugaring)
|
coreLibraryDesugaring(libs.desugaring)
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,9 +44,10 @@ import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import com.valentinilk.shimmer.shimmer
|
import com.valentinilk.shimmer.shimmer
|
||||||
import it.vfsfitvnm.synchronizedlyrics.LujjjhLyrics
|
import it.vfsfitvnm.kugou.KuGou
|
||||||
import it.vfsfitvnm.vimusic.Database
|
import it.vfsfitvnm.vimusic.Database
|
||||||
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
|
||||||
import it.vfsfitvnm.vimusic.R
|
import it.vfsfitvnm.vimusic.R
|
||||||
|
@ -73,6 +74,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Lyrics(
|
fun Lyrics(
|
||||||
|
@ -81,6 +83,7 @@ fun Lyrics(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
size: Dp,
|
size: Dp,
|
||||||
mediaMetadataProvider: () -> MediaMetadata,
|
mediaMetadataProvider: () -> MediaMetadata,
|
||||||
|
durationProvider: () -> Long,
|
||||||
onLyricsUpdate: (Boolean, String, String) -> Unit,
|
onLyricsUpdate: (Boolean, String, String) -> Unit,
|
||||||
nestedScrollConnectionProvider: () -> NestedScrollConnection,
|
nestedScrollConnectionProvider: () -> NestedScrollConnection,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
|
@ -119,7 +122,20 @@ fun Lyrics(
|
||||||
|
|
||||||
if (isShowingSynchronizedLyrics) {
|
if (isShowingSynchronizedLyrics) {
|
||||||
val mediaMetadata = mediaMetadataProvider()
|
val mediaMetadata = mediaMetadataProvider()
|
||||||
LujjjhLyrics.forSong(mediaMetadata.artist?.toString() ?: "", mediaMetadata.title?.toString() ?: "")
|
var duration = withContext(Dispatchers.Main) {
|
||||||
|
durationProvider()
|
||||||
|
}
|
||||||
|
|
||||||
|
while (duration == C.TIME_UNSET) {
|
||||||
|
delay(100)
|
||||||
|
duration = withContext(Dispatchers.Main) {
|
||||||
|
durationProvider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
KuGou.lyrics(mediaMetadata.artist?.toString() ?: "", mediaMetadata.title?.toString() ?: "", duration)?.map {
|
||||||
|
it?.value
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
YouTube.next(mediaId, null)?.map { nextResult -> nextResult.lyrics?.text()?.getOrNull() }
|
YouTube.next(mediaId, null)?.map { nextResult -> nextResult.lyrics?.text()?.getOrNull() }
|
||||||
}?.map { newLyrics ->
|
}?.map { newLyrics ->
|
||||||
|
@ -224,7 +240,7 @@ fun Lyrics(
|
||||||
val player = LocalPlayerServiceBinder.current?.player ?: return@AnimatedVisibility
|
val player = LocalPlayerServiceBinder.current?.player ?: return@AnimatedVisibility
|
||||||
|
|
||||||
val synchronizedLyrics = remember(lyrics) {
|
val synchronizedLyrics = remember(lyrics) {
|
||||||
SynchronizedLyrics(lyrics) {
|
SynchronizedLyrics(KuGou.Lyrics(lyrics).sentences) {
|
||||||
player.currentPosition
|
player.currentPosition
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -285,7 +301,8 @@ fun Lyrics(
|
||||||
Menu {
|
Menu {
|
||||||
MenuEntry(
|
MenuEntry(
|
||||||
icon = R.drawable.time,
|
icon = R.drawable.time,
|
||||||
text = "Show ${if (isShowingSynchronizedLyrics) "static" else "synchronized"} lyrics",
|
text = "Show ${if (isShowingSynchronizedLyrics) "un" else ""}synchronized lyrics",
|
||||||
|
secondaryText = if (isShowingSynchronizedLyrics) null else "Provided by kugou.com",
|
||||||
onClick = {
|
onClick = {
|
||||||
menuState.hide()
|
menuState.hide()
|
||||||
isShowingSynchronizedLyrics = !isShowingSynchronizedLyrics
|
isShowingSynchronizedLyrics = !isShowingSynchronizedLyrics
|
||||||
|
@ -336,9 +353,13 @@ fun Lyrics(
|
||||||
onClick = {
|
onClick = {
|
||||||
menuState.hide()
|
menuState.hide()
|
||||||
query {
|
query {
|
||||||
|
if (isShowingSynchronizedLyrics) {
|
||||||
|
Database.updateSynchronizedLyrics(mediaId, null)
|
||||||
|
} else {
|
||||||
Database.updateLyrics(mediaId, null)
|
Database.updateLyrics(mediaId, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -125,6 +125,7 @@ fun Thumbnail(
|
||||||
},
|
},
|
||||||
size = thumbnailSizeDp,
|
size = thumbnailSizeDp,
|
||||||
mediaMetadataProvider = mediaItem::mediaMetadata,
|
mediaMetadataProvider = mediaItem::mediaMetadata,
|
||||||
|
durationProvider = player::getDuration,
|
||||||
nestedScrollConnectionProvider = nestedScrollConnectionProvider,
|
nestedScrollConnectionProvider = nestedScrollConnectionProvider,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -3,11 +3,8 @@ package it.vfsfitvnm.vimusic.utils
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import it.vfsfitvnm.synchronizedlyrics.parseSentences
|
|
||||||
|
|
||||||
class SynchronizedLyrics(text: String, private val positionProvider: () -> Long) {
|
|
||||||
val sentences = parseSentences(text)
|
|
||||||
|
|
||||||
|
class SynchronizedLyrics(val sentences: List<Pair<Long, String>>, private val positionProvider: () -> Long) {
|
||||||
var index by mutableStateOf(currentIndex)
|
var index by mutableStateOf(currentIndex)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
|
22
kugou/build.gradle.kts
Normal file
22
kugou/build.gradle.kts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
plugins {
|
||||||
|
kotlin("jvm")
|
||||||
|
@Suppress("DSL_SCOPE_VIOLATION")
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets.all {
|
||||||
|
java.srcDir("src/$name/kotlin")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.kotlin.coroutines)
|
||||||
|
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.client.encoding)
|
||||||
|
implementation(libs.ktor.client.serialization)
|
||||||
|
implementation(libs.ktor.serialization.json)
|
||||||
|
|
||||||
|
testImplementation(testLibs.junit)
|
||||||
|
}
|
190
kugou/src/main/kotlin/it/vfsfitvnm/kugou/KuGou.kt
Normal file
190
kugou/src/main/kotlin/it/vfsfitvnm/kugou/KuGou.kt
Normal file
|
@ -0,0 +1,190 @@
|
||||||
|
package it.vfsfitvnm.kugou
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.engine.cio.CIO
|
||||||
|
import io.ktor.client.plugins.BrowserUserAgent
|
||||||
|
import io.ktor.client.plugins.compression.ContentEncoding
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.client.plugins.defaultRequest
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.parameter
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.encodeURLParameter
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import io.ktor.util.decodeBase64String
|
||||||
|
import it.vfsfitvnm.kugou.models.DownloadLyricsResponse
|
||||||
|
import it.vfsfitvnm.kugou.models.SearchLyricsResponse
|
||||||
|
import it.vfsfitvnm.kugou.models.SearchSongResponse
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
object KuGou {
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
private val client by lazy {
|
||||||
|
HttpClient(CIO) {
|
||||||
|
BrowserUserAgent()
|
||||||
|
|
||||||
|
expectSuccess = true
|
||||||
|
|
||||||
|
install(ContentNegotiation) {
|
||||||
|
val feature = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
json(feature)
|
||||||
|
json(feature, ContentType.Text.Html)
|
||||||
|
json(feature, ContentType.Text.Plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
install(ContentEncoding) {
|
||||||
|
gzip()
|
||||||
|
deflate()
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRequest {
|
||||||
|
url("https://krcs.kugou.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun lyrics(artist: String, title: String, duration: Long): Result<Lyrics?>? {
|
||||||
|
return runCatching {
|
||||||
|
for (info in searchSong(keyword(artist, title))) {
|
||||||
|
if (info.duration >= duration / 1000 - 2 && info.duration <= duration / 1000 + 2) {
|
||||||
|
searchLyrics(info.hash).firstOrNull()?.let { candidate ->
|
||||||
|
return@runCatching downloadLyrics(
|
||||||
|
candidate.id,
|
||||||
|
candidate.accessKey
|
||||||
|
).normalize(title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
null
|
||||||
|
}.recoverIfCancelled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadLyrics(id: Long, accessKey: String): Lyrics {
|
||||||
|
return client.get("/download") {
|
||||||
|
parameter("ver", 1)
|
||||||
|
parameter("man", "yes")
|
||||||
|
parameter("client", "pc")
|
||||||
|
parameter("fmt", "lrc")
|
||||||
|
parameter("id", id)
|
||||||
|
parameter("accesskey", accessKey)
|
||||||
|
}.body<DownloadLyricsResponse>().content.decodeBase64String().let(::Lyrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun searchLyrics(hash: String): List<SearchLyricsResponse.Candidate> {
|
||||||
|
return client.get("/search") {
|
||||||
|
parameter("ver", 1)
|
||||||
|
parameter("man", "yes")
|
||||||
|
parameter("client", "mobi")
|
||||||
|
parameter("hash", hash)
|
||||||
|
}.body<SearchLyricsResponse>().candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun searchSong(keyword: String): List<SearchSongResponse.Data.Info> {
|
||||||
|
return client.get("https://mobileservice.kugou.com/api/v3/search/song") {
|
||||||
|
parameter("version", 9108)
|
||||||
|
parameter("plat", 0)
|
||||||
|
parameter("pagesize", 5)
|
||||||
|
parameter("showtype", 0)
|
||||||
|
url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false))
|
||||||
|
}.body<SearchSongResponse>().data.info
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun keyword(artist: String, title: String): String {
|
||||||
|
val (newTitle, featuring) = title.extract(" (feat. ", ')')
|
||||||
|
|
||||||
|
val newArtist = (if (featuring.isEmpty()) artist else "$artist, $featuring")
|
||||||
|
.replace(", ", "、")
|
||||||
|
.replace(" & ", "、")
|
||||||
|
.replace(".", "")
|
||||||
|
|
||||||
|
return "$newArtist - $newTitle"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.extract(startDelimiter: String, endDelimiter: Char): Pair<String, String> {
|
||||||
|
val startIndex = indexOf(startDelimiter)
|
||||||
|
|
||||||
|
if (startIndex == -1) return this to ""
|
||||||
|
|
||||||
|
val endIndex = indexOf(endDelimiter, startIndex)
|
||||||
|
|
||||||
|
if (endIndex == -1) return this to ""
|
||||||
|
|
||||||
|
return removeRange(startIndex, endIndex + 1) to substring(startIndex + startDelimiter.length, endIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class Lyrics(val value: String) : CharSequence by value {
|
||||||
|
val sentences: List<Pair<Long, String>>
|
||||||
|
get() = mutableListOf(0L to "").apply {
|
||||||
|
for (line in value.trim().lines()) {
|
||||||
|
try {
|
||||||
|
val position = line.take(10).run {
|
||||||
|
get(8).digitToInt() * 10L +
|
||||||
|
get(7).digitToInt() * 100 +
|
||||||
|
get(5).digitToInt() * 1000 +
|
||||||
|
get(4).digitToInt() * 10000 +
|
||||||
|
get(2).digitToInt() * 60 * 1000 +
|
||||||
|
get(1).digitToInt() * 600 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
add(position to line.substring(10))
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalize(title: String): Lyrics {
|
||||||
|
var toDrop = 0
|
||||||
|
var maybeToDrop = 0
|
||||||
|
|
||||||
|
val text = value.replace("\r\n", "\n").trim()
|
||||||
|
|
||||||
|
for (line in text.lineSequence()) {
|
||||||
|
if (line.startsWith("[ti:") ||
|
||||||
|
line.startsWith("[ar:") ||
|
||||||
|
line.startsWith("[al:") ||
|
||||||
|
line.startsWith("[by:") ||
|
||||||
|
line.startsWith("[hash:") ||
|
||||||
|
line.startsWith("[sign:") ||
|
||||||
|
line.startsWith("[qq:") ||
|
||||||
|
line.startsWith("[total:") ||
|
||||||
|
line.startsWith("[offset:") ||
|
||||||
|
line.startsWith("[id:") ||
|
||||||
|
line.containsAt("]Written by:", 9) ||
|
||||||
|
line.containsAt("]Lyrics by:", 9) ||
|
||||||
|
line.containsAt("]Composed by:", 9) ||
|
||||||
|
line.containsAt("]Producer:", 9) ||
|
||||||
|
line.containsAt("]作曲 : ", 9) ||
|
||||||
|
line.containsAt("]作词 : ", 9) ||
|
||||||
|
line.containsAt("]$title", 9)
|
||||||
|
) {
|
||||||
|
toDrop += line.length + 1 + maybeToDrop
|
||||||
|
maybeToDrop = 0
|
||||||
|
} else {
|
||||||
|
if (maybeToDrop == 0) {
|
||||||
|
maybeToDrop = line.length + 1
|
||||||
|
} else {
|
||||||
|
maybeToDrop = 0
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Lyrics(text.drop(toDrop + maybeToDrop).removeHtmlEntities())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.containsAt(charSequence: CharSequence, startIndex: Int): Boolean =
|
||||||
|
regionMatches(startIndex, charSequence, 0, charSequence.length)
|
||||||
|
|
||||||
|
private fun String.removeHtmlEntities(): String =
|
||||||
|
replace("'", "'")
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package it.vfsfitvnm.synchronizedlyrics
|
package it.vfsfitvnm.kugou
|
||||||
|
|
||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
package it.vfsfitvnm.kugou.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class DownloadLyricsResponse(
|
||||||
|
val content: String
|
||||||
|
)
|
|
@ -0,0 +1,16 @@
|
||||||
|
package it.vfsfitvnm.kugou.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class SearchLyricsResponse(
|
||||||
|
val candidates: List<Candidate>
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
internal class Candidate(
|
||||||
|
val id: Long,
|
||||||
|
@SerialName("accesskey") val accessKey: String,
|
||||||
|
val duration: Long
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
package it.vfsfitvnm.kugou.models
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class SearchSongResponse(
|
||||||
|
val data: Data
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
internal data class Data(
|
||||||
|
val info: List<Info>
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
internal data class Info(
|
||||||
|
val duration: Long,
|
||||||
|
val hash: String
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -64,4 +64,4 @@ include(":compose-routing")
|
||||||
include(":compose-reordering")
|
include(":compose-reordering")
|
||||||
include(":youtube-music")
|
include(":youtube-music")
|
||||||
include(":ktor-client-brotli")
|
include(":ktor-client-brotli")
|
||||||
include(":synchronized-lyrics")
|
include(":kugou")
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
plugins {
|
|
||||||
kotlin("jvm")
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceSets.all {
|
|
||||||
java.srcDir("src/$name/kotlin")
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.kotlin.coroutines)
|
|
||||||
testImplementation(testLibs.junit)
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
package it.vfsfitvnm.synchronizedlyrics
|
|
||||||
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import java.net.URL
|
|
||||||
import java.net.URLEncoder
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
object LujjjhLyrics {
|
|
||||||
suspend fun forSong(artist: String, title: String): Result<String?>? {
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
runCatching {
|
|
||||||
val artistParameter = URLEncoder.encode(artist, "UTF-8")
|
|
||||||
val titleParameter = URLEncoder.encode(title, "UTF-8")
|
|
||||||
|
|
||||||
URL("https://lyrics-api.lujjjh.com?artist=$artistParameter&name=$titleParameter")
|
|
||||||
.openConnection()
|
|
||||||
.getInputStream()
|
|
||||||
.bufferedReader()
|
|
||||||
.readText()
|
|
||||||
}.recoverIfCancelled()?.recoverCatching { throwable ->
|
|
||||||
if (throwable is FileNotFoundException) null else throw throwable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,24 +0,0 @@
|
||||||
package it.vfsfitvnm.synchronizedlyrics
|
|
||||||
|
|
||||||
fun parseSentences(text: String): List<Pair<Long, String>> {
|
|
||||||
return mutableListOf(0L to "").apply {
|
|
||||||
for (line in text.trim().lines()) {
|
|
||||||
val sentence = line.substring(10)
|
|
||||||
|
|
||||||
if (sentence.startsWith(" 作词 : ") || sentence.startsWith(" 作曲 : ")) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
val position = line.take(10).run {
|
|
||||||
get(8).digitToInt() * 10L +
|
|
||||||
get(7).digitToInt() * 100 +
|
|
||||||
get(5).digitToInt() * 1000 +
|
|
||||||
get(4).digitToInt() * 10000 +
|
|
||||||
get(2).digitToInt() * 60 * 1000 +
|
|
||||||
get(1).digitToInt() * 600 * 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
add(position to sentence)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in a new issue