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)
|
||||
|
||||
implementation(projects.youtubeMusic)
|
||||
implementation(projects.synchronizedLyrics)
|
||||
implementation(projects.kugou)
|
||||
|
||||
coreLibraryDesugaring(libs.desugaring)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -125,6 +125,7 @@ fun Thumbnail(
|
|||
},
|
||||
size = thumbnailSizeDp,
|
||||
mediaMetadataProvider = mediaItem::mediaMetadata,
|
||||
durationProvider = player::getDuration,
|
||||
nestedScrollConnectionProvider = nestedScrollConnectionProvider,
|
||||
)
|
||||
|
||||
|
|
|
@ -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
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
|
||||
|
|
@ -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(":youtube-music")
|
||||
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