diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a2b70f83f..449cf58c7 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -128,6 +128,7 @@ dependencies { implementation(projects.innertube) implementation(projects.kugou) + implementation(projects.lrclib) coreLibraryDesugaring(libs.desugaring) diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt index cc31c882d..d0322b395 100644 --- a/app/src/main/java/com/zionhuang/music/App.kt +++ b/app/src/main/java/com/zionhuang/music/App.kt @@ -10,7 +10,6 @@ import coil.ImageLoaderFactory import coil.disk.DiskCache import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.YouTubeLocale -import com.zionhuang.kugou.KuGou import com.zionhuang.music.constants.* import com.zionhuang.music.extensions.* import com.zionhuang.music.utils.dataStore @@ -44,9 +43,6 @@ class App : Application(), ImageLoaderFactory { ?: languageTag.takeIf { it in LanguageCodeToName } ?: "en" ) - if (languageTag == "zh-TW") { - KuGou.useTraditionalChinese = true - } if (dataStore[ProxyEnabledKey] == true) { try { diff --git a/app/src/main/java/com/zionhuang/music/lyrics/LrcLibLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/LrcLibLyricsProvider.kt new file mode 100644 index 000000000..273f336db --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/lyrics/LrcLibLyricsProvider.kt @@ -0,0 +1,16 @@ +package com.zionhuang.music.lyrics + +import android.content.Context +import com.zionhuang.lrclib.LrcLib + +object LrcLibLyricsProvider : LyricsProvider { + override val name = "LrcLib" + override fun isEnabled(context: Context) = true + + override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result = + LrcLib.getLyrics(title, artist, duration) + + override suspend fun getAllLyrics(id: String, title: String, artist: String, duration: Int, callback: (String) -> Unit) { + LrcLib.getAllLyrics(title, artist, duration, null, callback) + } +} diff --git a/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt b/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt index 86b791398..487419d31 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt @@ -11,7 +11,7 @@ import javax.inject.Inject class LyricsHelper @Inject constructor( @ApplicationContext private val context: Context, ) { - private val lyricsProviders = listOf(YouTubeSubtitleLyricsProvider, KuGouLyricsProvider, YouTubeLyricsProvider) + private val lyricsProviders = listOf(LrcLibLyricsProvider,KuGouLyricsProvider, YouTubeSubtitleLyricsProvider, YouTubeLyricsProvider) private val cache = LruCache>(MAX_CACHE_SIZE) suspend fun getLyrics(mediaMetadata: MediaMetadata): String { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e98cd0bc6..5323f3a3e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -60,6 +60,7 @@ hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor_client_cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-encoding = { group = "io.ktor", name = "ktor-client-encoding", version.ref = "ktor" } diff --git a/kugou/src/main/java/com/zionhuang/kugou/KuGou.kt b/kugou/src/main/java/com/zionhuang/kugou/KuGou.kt index 75a6eb57a..c174c4aa6 100644 --- a/kugou/src/main/java/com/zionhuang/kugou/KuGou.kt +++ b/kugou/src/main/java/com/zionhuang/kugou/KuGou.kt @@ -1,202 +1,205 @@ package com.zionhuang.kugou -import com.github.houbb.opencc4j.util.ZhConverterUtil import com.zionhuang.kugou.models.DownloadLyricsResponse 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.engine.okhttp.OkHttp +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 kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json -import java.lang.Character.UnicodeScript -import java.lang.Integer.min import kotlin.math.abs /** * KuGou Lyrics Library * Modified from [ViMusic](https://github.com/vfsfitvnm/ViMusic) + * Modified from [ViTune](https://github.com/25huizengek1/ViTune) */ 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 + private val client by lazy { + HttpClient(OkHttp) { + 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() } - json(json) - json(json, ContentType.Text.Html) - json(json, ContentType.Text.Plain) - } - install(ContentEncoding) { - gzip() - deflate() + defaultRequest { + url("https://krcs.kugou.com") + } } } - suspend fun getLyrics(title: String, artist: String, duration: Int): Result = 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 getLyrics(artist: String, title: String, duration: Int) = runCatching { + val keyword = keyword(artist, title) + val infoByKeyword = searchSong(keyword) + + if (infoByKeyword.isNotEmpty()) { + var tolerance = 0 + + while (tolerance <= 5) { + for (info in infoByKeyword) { + if (info.duration >= duration - tolerance && info.duration <= duration + tolerance) { + searchLyricsByHash(info.hash).firstOrNull()?.let { candidate -> + return@runCatching downloadLyrics( + candidate.id, + candidate.accesskey + ).normalize().value + } + } + } + + tolerance++ + } + } + + searchLyricsByKeyword(keyword).firstOrNull()?.let { candidate -> + return@runCatching downloadLyrics( + candidate.id, + candidate.accesskey + ).normalize().value + } + + throw IllegalStateException("Lyrics endpoint not found") } suspend fun getAllLyrics(title: String, artist: String, duration: Int, callback: (String) -> Unit) { - val keyword = generateKeyword(title, artist) - searchSongs(keyword).data.info.forEach { + val keyword = keyword(title, artist) + searchSong(keyword).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) + searchLyricsByHash(it.hash).firstOrNull()?.let { candidate -> + downloadLyrics(candidate.id, candidate.accesskey).normalize().value.let(callback) } } } - searchLyricsByKeyword(keyword, duration).candidates.forEach { candidate -> + searchLyricsByKeyword(keyword).forEach { candidate -> downloadLyrics(candidate.id, candidate.accesskey) - .content - .decodeBase64String() - .normalize(keyword) - ?.let(callback) + .normalize().value.let(callback) } } - suspend fun getLyricsCandidate(keyword: Pair, 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() - if (candidate != null) return candidate - } - } - return searchLyricsByKeyword(keyword, duration).candidates.firstOrNull() - } - - private suspend fun searchSongs(keyword: Pair) = + private suspend fun downloadLyrics(id: Long, accessKey: String) = client.get("/download") { + parameter("ver", 1) + parameter("man", "yes") + parameter("client", "pc") + parameter("fmt", "lrc") + parameter("id", id) + parameter("accesskey", accessKey) + }.body().content.decodeBase64String().let(KuGou::Lyrics) + + private suspend fun searchLyricsByHash(hash: String) = client.get("/search") { + parameter("ver", 1) + parameter("man", "yes") + parameter("client", "mobi") + parameter("hash", hash) + }.body().candidates + + private suspend fun searchLyricsByKeyword(keyword: String) = client.get("/search") { + parameter("ver", 1) + parameter("man", "yes") + parameter("client", "mobi") + url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) + }.body().candidates + + private suspend fun searchSong(keyword: String) = client.get("https://mobileservice.kugou.com/api/v3/search/song") { parameter("version", 9108) parameter("plat", 0) parameter("pagesize", 8) parameter("showtype", 0) - url.encodedParameters.append("keyword", "${keyword.first} - ${keyword.second}".encodeURLParameter(spaceToPlus = false)) - }.body() - - private suspend fun searchLyricsByKeyword(keyword: Pair, 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)) - }.body() - - private suspend fun searchLyricsByHash(hash: String) = - client.get("https://lyrics.kugou.com/search") { - parameter("ver", 1) - parameter("man", "yes") - parameter("client", "pc") - parameter("hash", hash) - }.body() - - private suspend fun downloadLyrics(id: Long, accessKey: String) = - client.get("https://lyrics.kugou.com/download") { - parameter("fmt", "lrc") - parameter("charset", "utf8") - parameter("client", "pc") - parameter("ver", 1) - parameter("id", id) - parameter("accesskey", accessKey) - }.body() - - 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? = - replace("'", "'").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 + url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) + }.body().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" + } + + @Suppress("ReturnCount") + private fun String.extract(startDelimiter: String, endDelimiter: Char): Pair { + val startIndex = indexOf(startDelimiter).takeIf { it != -1 } ?: return this to "" + val endIndex = indexOf(endDelimiter, startIndex).takeIf { it != -1 } ?: return this to "" + + return removeRange(startIndex, endIndex + 1) to substring(startIndex + startDelimiter.length, endIndex) + } + + @JvmInline + value class Lyrics(val value: String) { + @Suppress("CyclomaticComplexMethod") + fun normalize(): Lyrics { + var toDrop = 0 + var maybeToDrop = 0 + + val text = value.replace("\r\n", "\n").trim() + + for (line in text.lineSequence()) when { + 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) -> { + toDrop += line.length + 1 + maybeToDrop + maybeToDrop = 0 } - } - 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 + + maybeToDrop == 0 -> maybeToDrop = line.length + 1 + + else -> { + maybeToDrop = 0 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 - - private fun String.toSimplifiedChinese() = ZhConverterUtil.toSimple(this) - private fun String.toTraditionalChinese() = ZhConverterUtil.toTraditional(this) - - @Suppress("RegExpRedundantEscape") - private val ACCEPTED_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\].*".toRegex() - private val BANNED_REGEX = ".+].+[::].+".toRegex() + return Lyrics(text.drop(toDrop + maybeToDrop).removeHtmlEntities()) + } - private val JapaneseUnicodeScript = hashSetOf( - UnicodeScript.HIRAGANA, - UnicodeScript.KATAKANA, - ) + private fun String.containsAt(charSequence: CharSequence, startIndex: Int) = + regionMatches(startIndex, charSequence, 0, charSequence.length) + private fun String.removeHtmlEntities() = replace("'", "'") + } private const val DURATION_TOLERANCE = 8 } diff --git a/lrclib/.gitignore b/lrclib/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/lrclib/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/lrclib/build.gradle.kts b/lrclib/build.gradle.kts new file mode 100644 index 000000000..9f087078c --- /dev/null +++ b/lrclib/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + kotlin("jvm") + @Suppress("DSL_SCOPE_VIOLATION") + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.json) + implementation(libs.ktor.client.encoding) + implementation(libs.opencc4j) + testImplementation(libs.junit) +} \ No newline at end of file diff --git a/lrclib/src/main/java/com/zionhuang/lrclib/LrcLib.kt b/lrclib/src/main/java/com/zionhuang/lrclib/LrcLib.kt new file mode 100644 index 000000000..3b2c09132 --- /dev/null +++ b/lrclib/src/main/java/com/zionhuang/lrclib/LrcLib.kt @@ -0,0 +1,104 @@ +package com.zionhuang.lrclib + +import com.zionhuang.lrclib.models.Track +import com.zionhuang.lrclib.models.bestMatchingFor +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +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.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json +import kotlin.math.abs + +object LrcLib { + private val client by lazy { + HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + isLenient = true + ignoreUnknownKeys = true + } + ) + } + + defaultRequest { + url("https://lrclib.net") + } + + expectSuccess = true + } + } + + private suspend fun queryLyrics(artist: String, title: String, album: String? = null) = + client.get("/api/search") { + parameter("track_name", title) + parameter("artist_name", artist) + if (album != null) parameter("album_name", album) + }.body>() +//}.body>().filter { it.syncedLyrics != null } + + suspend fun getLyrics( + title: String, + artist: String, + duration: Int, + album: String? = null + ) = runCatching { + val tracks = queryLyrics(artist, title, album) + + val res = tracks.bestMatchingFor(duration)?.syncedLyrics?.let(LrcLib::Lyrics) + if (res != null) + return@runCatching res.text + else { + throw IllegalStateException("Lyrics unavailable") + } + } + + suspend fun getAllLyrics(title: String, artist: String, duration: Int, album: String? = null, callback: (String) -> Unit) { + val tracks = queryLyrics(artist, title, album) + var count = 0 + var plain = 0 + tracks.forEach { + if (count <= 4) { + if (it.syncedLyrics != null && abs(it.duration - duration) <= 2) { + count++ + it.syncedLyrics.let(callback) + } + if (it.plainLyrics != null && abs(it.duration - duration) <= 2 && plain == 0) { + count++ + plain++ + it.plainLyrics.let(callback) + } + } + } + } + + + suspend fun lyrics(artist: String, title: String) = runCatching { + queryLyrics(artist = artist, title = title, album = null) + } + + @JvmInline + value class Lyrics(val text: String) { + val sentences + get() = runCatching { + buildMap { + put(0L, "") + text.trim().lines().filter { it.length >= 10 }.forEach { + put( + it[8].digitToInt() * 10L + + it[7].digitToInt() * 100 + + it[5].digitToInt() * 1000 + + it[4].digitToInt() * 10000 + + it[2].digitToInt() * 60 * 1000 + + it[1].digitToInt() * 600 * 1000, + it.substring(10) + ) + } + } + }.getOrNull() + } +} diff --git a/lrclib/src/main/java/com/zionhuang/lrclib/models/Track.kt b/lrclib/src/main/java/com/zionhuang/lrclib/models/Track.kt new file mode 100644 index 000000000..fe528a0f0 --- /dev/null +++ b/lrclib/src/main/java/com/zionhuang/lrclib/models/Track.kt @@ -0,0 +1,19 @@ +package com.zionhuang.lrclib.models + +import kotlinx.serialization.Serializable +import kotlin.math.abs +import kotlin.time.Duration + +@Serializable +data class Track( + val id: Int, + val trackName: String, + val artistName: String, + val duration: Long, + val plainLyrics: String?, + val syncedLyrics: String? +) + +internal fun List.bestMatchingFor(duration: Int) = + firstOrNull { abs(it.duration.toInt() - duration) <= 2 } + diff --git a/settings.gradle.kts b/settings.gradle.kts index 87f2e6060..cd04b2fa0 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,4 +14,5 @@ rootProject.name = "InnerTune" include(":app") include(":innertube") include(":kugou") +include(":lrclib") include(":material-color-utilities")