Skip to content

Commit

Permalink
kugou: Improve code and delete cc4j
Browse files Browse the repository at this point in the history
  • Loading branch information
BobbyESP authored and DD3Boh committed Apr 22, 2024
1 parent b946fca commit 7d4f2fe
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 127 deletions.
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ plugins {
android {
namespace = "com.dd3boh.outertune"
compileSdk = 34
buildToolsVersion = "34.0.0"

defaultConfig {
applicationId = "com.dd3boh.outertune"
minSdk = 21
Expand Down
1 change: 0 additions & 1 deletion kugou/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,5 @@ dependencies {
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.json)
implementation(libs.ktor.client.encoding)
implementation(libs.opencc4j)
testImplementation(libs.junit)
}
223 changes: 100 additions & 123 deletions kugou/src/main/java/com/zionhuang/kugou/KuGou.kt
Original file line number Diff line number Diff line change
@@ -1,83 +1,85 @@
package com.zionhuang.kugou

import com.github.houbb.opencc4j.util.ZhConverterUtil
import com.zionhuang.kugou.models.DownloadLyricsResponse
import com.zionhuang.kugou.models.Keyword
import com.zionhuang.kugou.models.SearchLyricsResponse
import com.zionhuang.kugou.models.SearchSongResponse
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.compression.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.util.*
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.compression.ContentEncoding
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
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 kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import java.lang.Character.UnicodeScript
import java.lang.Integer.min
import kotlin.math.abs

@OptIn(ExperimentalSerializationApi::class)
private val client = HttpClient {
expectSuccess = true

install(ContentNegotiation) {
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
json(json)
json(json, ContentType.Text.Html)
json(json, ContentType.Text.Plain)
}

install(ContentEncoding) {
gzip()
deflate()
}
}

private const val PAGE_SIZE = 8
private const val HEAD_CUT_LIMIT = 30

/**
* KuGou Lyrics Library
* Modified from [ViMusic](https://github.com/vfsfitvnm/ViMusic)
*/
object KuGou {
var useTraditionalChinese: Boolean = false

@OptIn(ExperimentalSerializationApi::class)
private val client = HttpClient {
expectSuccess = true

install(ContentNegotiation) {
val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
}
json(json)
json(json, ContentType.Text.Html)
json(json, ContentType.Text.Plain)
}

install(ContentEncoding) {
gzip()
deflate()
suspend fun getLyrics(title: String, artist: String, duration: Int): Result<String> =
runCatching {
val keyword = generateKeyword(title, artist)
getLyricsCandidate(keyword, duration)?.let { candidate ->
downloadLyrics(candidate.id, candidate.accesskey).content.decodeBase64String()
.normalize()
} ?: throw IllegalStateException("No lyrics candidate")
}
}

suspend fun getLyrics(title: String, artist: String, duration: Int): Result<String> = runCatching {
val keyword = generateKeyword(title, artist)
getLyricsCandidate(keyword, duration)?.let { candidate ->
downloadLyrics(candidate.id, candidate.accesskey).content.decodeBase64String().normalize(keyword)
} ?: throw IllegalStateException("No lyrics candidate")
}

suspend fun getAllLyrics(title: String, artist: String, duration: Int, callback: (String) -> Unit) {
suspend fun getAllPossibleLyricsOptions(
title: String, artist: String, duration: Int, callback: (String) -> Unit
) {
val keyword = generateKeyword(title, artist)
searchSongs(keyword).data.info.forEach {
if (duration == -1 || abs(it.duration - duration) <= DURATION_TOLERANCE) {
searchLyricsByHash(it.hash).candidates.firstOrNull()?.let { candidate ->
downloadLyrics(candidate.id, candidate.accesskey)
.content
.decodeBase64String()
.normalize(keyword)
?.let(callback)
downloadLyrics(candidate.id, candidate.accesskey).content.decodeBase64String()
.normalize().let(callback)
}
}
}
searchLyricsByKeyword(keyword, duration).candidates.forEach { candidate ->
downloadLyrics(candidate.id, candidate.accesskey)
.content
.decodeBase64String()
.normalize(keyword)
?.let(callback)
downloadLyrics(candidate.id, candidate.accesskey).content.decodeBase64String()
.normalize().let(callback)
}
}

suspend fun getLyricsCandidate(keyword: Pair<String, String>, duration: Int): SearchLyricsResponse.Candidate? {
suspend fun getLyricsCandidate(
keyword: Keyword, duration: Int
): SearchLyricsResponse.Candidate? {
searchSongs(keyword).data.info.forEach { song ->
if (duration == -1 || abs(song.duration - duration) <= DURATION_TOLERANCE) { // if duration == -1, we don't care duration
val candidate = searchLyricsByHash(song.hash).candidates.firstOrNull()
Expand All @@ -87,22 +89,30 @@ object KuGou {
return searchLyricsByKeyword(keyword, duration).candidates.firstOrNull()
}

private suspend fun searchSongs(keyword: Pair<String, String>) =
suspend fun searchSongs(keyword: Keyword) =
client.get("https://mobileservice.kugou.com/api/v3/search/song") {
parameter("version", 9108)
parameter("plat", 0)
parameter("pagesize", 8)
parameter("pagesize", PAGE_SIZE)
parameter("showtype", 0)
url.encodedParameters.append("keyword", "${keyword.first} - ${keyword.second}".encodeURLParameter(spaceToPlus = false))
url.encodedParameters.append(
"keyword",
"${keyword.title} - ${keyword.artist}".encodeURLParameter(spaceToPlus = false)
)
}.body<SearchSongResponse>()

private suspend fun searchLyricsByKeyword(keyword: Pair<String, String>, duration: Int) =
private suspend fun searchLyricsByKeyword(keyword: Keyword, duration: Int) =
client.get("https://lyrics.kugou.com/search") {
parameter("ver", 1)
parameter("man", "yes")
parameter("client", "pc")
parameter("duration", duration.takeIf { it != -1 }?.times(1000)) // if duration == -1, we don't care duration
url.encodedParameters.append("keyword", "${keyword.first} - ${keyword.second}".encodeURLParameter(spaceToPlus = false))
parameter(
"duration", duration.takeIf { it != -1 }?.times(1000)
) // if duration == -1, we don't care duration
url.encodedParameters.append(
"keyword",
"${keyword.title} - ${keyword.artist}".encodeURLParameter(spaceToPlus = false)
)
}.body<SearchLyricsResponse>()

private suspend fun searchLyricsByHash(hash: String) =
Expand All @@ -123,80 +133,47 @@ object KuGou {
parameter("accesskey", accessKey)
}.body<DownloadLyricsResponse>()

private fun normalizeTitle(title: String) = title
.replace("\\(.*\\)".toRegex(), "")
.replace("(.*)".toRegex(), "")
.replace("「.*」".toRegex(), "")
.replace("『.*』".toRegex(), "")
.replace("<.*>".toRegex(), "")
.replace("《.*》".toRegex(), "")
.replace("〈.*〉".toRegex(), "")
.replace("<.*>".toRegex(), "")

private fun normalizeArtist(artist: String) = artist
.replace(", ", "")
.replace(" & ", "")
.replace(".", "")
.replace("", "")
.replace("\\(.*\\)".toRegex(), "")
.replace("(.*)".toRegex(), "")

fun generateKeyword(title: String, artist: String) = normalizeTitle(title) to normalizeArtist(artist)

private fun String.normalize(keyword: Pair<String, String>): String? =
replace("&apos;", "'").lines().filter { line ->
line matches ACCEPTED_REGEX
}.let {
// Remove useless information such as singer, writer, composer, guitar, etc.
var headCutLine = 0
for (i in min(30, it.lastIndex) downTo 0) {
if (it[i] matches BANNED_REGEX) {
headCutLine = i + 1
break
private fun normalizeTitle(title: String) =
title.replace("\\(.*\\)".toRegex(), "").replace("(.*)".toRegex(), "")
.replace("「.*」".toRegex(), "").replace("『.*』".toRegex(), "")
.replace("<.*>".toRegex(), "").replace("《.*》".toRegex(), "")
.replace("〈.*〉".toRegex(), "").replace("<.*>".toRegex(), "")

private fun normalizeArtist(artist: String) =
artist.replace(", ", "").replace(" & ", "").replace(".", "").replace("", "")
.replace("\\(.*\\)".toRegex(), "").replace("(.*)".toRegex(), "")

fun generateKeyword(title: String, artist: String) =
Keyword(normalizeTitle(title), normalizeArtist(artist))

private fun String.normalize(): String =
replace("&apos;", "'").lines().filter { line -> line.matches(ACCEPTED_REGEX) }
.let { lines ->
// Remove useless information such as singer, writer, composer, guitar, etc.
var headCutLine = 0
for (i in min(HEAD_CUT_LIMIT, lines.lastIndex) downTo 0) {
if (lines[i].matches(BANNED_REGEX)) {
headCutLine = i + 1
break
}
}
}
it.drop(headCutLine)
}.let {
var tailCutLine = 0
for (i in min(it.size - 30, it.lastIndex) downTo 0) {
if (it[it.lastIndex - i] matches BANNED_REGEX) {
tailCutLine = i + 1
break
val filteredLines = lines.drop(headCutLine)

var tailCutLine = 0
for (i in min(lines.size - HEAD_CUT_LIMIT, lines.lastIndex) downTo 0) {
if (lines[lines.lastIndex - i].matches(BANNED_REGEX)) {
tailCutLine = i + 1
break
}
}
}
it.dropLast(tailCutLine)
}.takeIf {
it.isNotEmpty() && "纯音乐,请欣赏" !in it[0]
}?.let { lines ->
val firstLine = lines.firstOrNull()?.toSimplifiedChinese() ?: return@let lines
val (title, artist) = keyword
if (title.toSimplifiedChinese() in firstLine ||
artist.split("").any { it.toSimplifiedChinese() in firstLine }
) {
lines.drop(1)
} else lines
}?.joinToString(separator = "\n")?.let {
if (useTraditionalChinese) it.normalizeForTraditionalChinese()
else it
}

private fun String.normalizeForTraditionalChinese() =
if (none { c -> UnicodeScript.of(c.code) in JapaneseUnicodeScript }) toTraditionalChinese()
.replace('', '')
.replace('', '')
else this
val finalLines = filteredLines.dropLast(tailCutLine)

private fun String.toSimplifiedChinese() = ZhConverterUtil.toSimple(this)
private fun String.toTraditionalChinese() = ZhConverterUtil.toTraditional(this)
return@let finalLines.joinToString("\n")
}

@Suppress("RegExpRedundantEscape")
private val ACCEPTED_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\].*".toRegex()
private val BANNED_REGEX = ".+].+[::].+".toRegex()

private val JapaneseUnicodeScript = hashSetOf(
UnicodeScript.HIRAGANA,
UnicodeScript.KATAKANA,
)

private const val DURATION_TOLERANCE = 8
}
3 changes: 3 additions & 0 deletions kugou/src/main/java/com/zionhuang/kugou/models/Keyword.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.zionhuang.kugou.models

data class Keyword(val title: String, val artist: String)
31 changes: 29 additions & 2 deletions kugou/src/test/java/Test.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,35 @@ import org.junit.Test
class Test {
@Test
fun test() = runBlocking {
val candidates = KuGou.getLyricsCandidate(generateKeyword("千年以後 (After A Thousand Years)", "陳零九"), 285)
val candidates = KuGou.getLyricsCandidate(
generateKeyword("千年以後 (After A Thousand Years)", "陳零九"),
285
)
assertTrue(candidates != null)
assertTrue(KuGou.getLyrics("楊丞琳", "點水", 259).isSuccess)
val downloadedLyrics = KuGou.getLyrics("楊丞琳", "點水", 259)
println(downloadedLyrics)
assertTrue(downloadedLyrics.isSuccess)
}

@Test
fun searchAlanWalkerSong() = runBlocking {
val songName = "Faded"
val artistName = "Alan Walker"

val keyword = generateKeyword(songName, artistName)
val song = KuGou.searchSongs(keyword)

assertTrue(song.data.info.isNotEmpty())

val candidates = KuGou.getLyricsCandidate(
keyword,
song.data.info.first().duration
)

assertTrue(candidates != null)

val downloadedLyrics = KuGou.getLyrics(songName, artistName, song.data.info.first().duration)
println(downloadedLyrics)
assertTrue(downloadedLyrics.isSuccess)
}
}

0 comments on commit 7d4f2fe

Please sign in to comment.