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) kapt(libs.room.compiler)
implementation(projects.youtubeMusic) implementation(projects.youtubeMusic)
implementation(projects.synchronizedLyrics) implementation(projects.kugou)
coreLibraryDesugaring(libs.desugaring) 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.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,7 +353,11 @@ fun Lyrics(
onClick = { onClick = {
menuState.hide() menuState.hide()
query { query {
Database.updateLyrics(mediaId, null) if (isShowingSynchronizedLyrics) {
Database.updateSynchronizedLyrics(mediaId, null)
} else {
Database.updateLyrics(mediaId, null)
}
} }
} }
) )

View file

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

View file

@ -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
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 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(":compose-reordering")
include(":youtube-music") include(":youtube-music")
include(":ktor-client-brotli") 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)
}
}
}