Add kugou as lyrics provider (#126)

This commit is contained in:
vfsfitvnm 2022-08-06 19:45:00 +02:00
parent a246f1f336
commit 3e00671122
16 changed files with 286 additions and 74 deletions

View file

@ -93,7 +93,7 @@ dependencies {
kapt(libs.room.compiler)
implementation(projects.youtubeMusic)
implementation(projects.synchronizedLyrics)
implementation(projects.kugou)
coreLibraryDesugaring(libs.desugaring)
}

View file

@ -44,9 +44,10 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.media3.common.C
import androidx.media3.common.MediaMetadata
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.synchronizedlyrics.LujjjhLyrics
import it.vfsfitvnm.kugou.KuGou
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.LocalPlayerServiceBinder
import it.vfsfitvnm.vimusic.R
@ -73,6 +74,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
@Composable
fun Lyrics(
@ -81,6 +83,7 @@ fun Lyrics(
onDismiss: () -> Unit,
size: Dp,
mediaMetadataProvider: () -> MediaMetadata,
durationProvider: () -> Long,
onLyricsUpdate: (Boolean, String, String) -> Unit,
nestedScrollConnectionProvider: () -> NestedScrollConnection,
modifier: Modifier = Modifier
@ -119,7 +122,20 @@ fun Lyrics(
if (isShowingSynchronizedLyrics) {
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 {
YouTube.next(mediaId, null)?.map { nextResult -> nextResult.lyrics?.text()?.getOrNull() }
}?.map { newLyrics ->
@ -224,7 +240,7 @@ fun Lyrics(
val player = LocalPlayerServiceBinder.current?.player ?: return@AnimatedVisibility
val synchronizedLyrics = remember(lyrics) {
SynchronizedLyrics(lyrics) {
SynchronizedLyrics(KuGou.Lyrics(lyrics).sentences) {
player.currentPosition
}
}
@ -285,7 +301,8 @@ fun Lyrics(
Menu {
MenuEntry(
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 = {
menuState.hide()
isShowingSynchronizedLyrics = !isShowingSynchronizedLyrics
@ -336,9 +353,13 @@ fun Lyrics(
onClick = {
menuState.hide()
query {
if (isShowingSynchronizedLyrics) {
Database.updateSynchronizedLyrics(mediaId, null)
} else {
Database.updateLyrics(mediaId, null)
}
}
}
)
}
}

View file

@ -125,6 +125,7 @@ fun Thumbnail(
},
size = thumbnailSizeDp,
mediaMetadataProvider = mediaItem::mediaMetadata,
durationProvider = player::getDuration,
nestedScrollConnectionProvider = nestedScrollConnectionProvider,
)

View file

@ -3,11 +3,8 @@ package it.vfsfitvnm.vimusic.utils
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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)
private set

22
kugou/build.gradle.kts Normal file
View 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)
}

View 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("&apos;", "'")
}
}

View file

@ -1,4 +1,4 @@
package it.vfsfitvnm.synchronizedlyrics
package it.vfsfitvnm.kugou
import kotlin.coroutines.cancellation.CancellationException

View file

@ -0,0 +1,8 @@
package it.vfsfitvnm.kugou.models
import kotlinx.serialization.Serializable
@Serializable
internal class DownloadLyricsResponse(
val content: String
)

View file

@ -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
)
}

View file

@ -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
)
}
}

View file

@ -64,4 +64,4 @@ include(":compose-routing")
include(":compose-reordering")
include(":youtube-music")
include(":ktor-client-brotli")
include(":synchronized-lyrics")
include(":kugou")

View file

@ -1,12 +0,0 @@
plugins {
kotlin("jvm")
}
sourceSets.all {
java.srcDir("src/$name/kotlin")
}
dependencies {
implementation(libs.kotlin.coroutines)
testImplementation(testLibs.junit)
}

View file

@ -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
}
}
}
}

View file

@ -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)
}
}
}