Skip to content

Commit

Permalink
Add Kugou lyrics source (closes #63)
Browse files Browse the repository at this point in the history
Add Kugou lyrics source which can be selected from the lyrics search screen
Add prefs option for default lyrics source
  • Loading branch information
toasterofbread committed Jul 17, 2023
1 parent f1a5571 commit 5cad48d
Show file tree
Hide file tree
Showing 17 changed files with 358 additions and 83 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,45 @@ import com.atilika.kuromoji.ipadic.Tokenizer
import com.toasterofbread.utils.hasKanjiAndHiragana
import com.toasterofbread.utils.isJP
import com.toasterofbread.utils.isKanji
import java.nio.channels.ClosedByInterruptException

fun createTokeniser(): Tokenizer {
try {
return Tokenizer()
}
catch (e: RuntimeException) {
if (e.cause is ClosedByInterruptException) {
throw InterruptedException()
}
else {
throw e
}
}
}

fun mergeAndFuriganiseTerms(tokeniser: Tokenizer, terms: List<SongLyrics.Term>): List<SongLyrics.Term> {
if (terms.isEmpty()) {
return emptyList()
}

val ret: MutableList<SongLyrics.Term> = mutableListOf()
val terms_to_process: MutableList<SongLyrics.Term> = mutableListOf()

for (term in terms) {
val text = term.subterms.single().text
if (text.any { it.isJP() }) {
terms_to_process.add(term)
}
else {
ret.addAll(_mergeAndFuriganiseTerms(tokeniser, terms_to_process))
terms_to_process.clear()
}
}

ret.addAll(_mergeAndFuriganiseTerms(tokeniser, terms_to_process))

return ret
}

private fun trimOkurigana(term: SongLyrics.Term.Text): List<SongLyrics.Term.Text> {
if (term.furi == null || !term.text.hasKanjiAndHiragana()) {
Expand Down Expand Up @@ -68,31 +107,6 @@ private fun trimOkurigana(term: SongLyrics.Term.Text): List<SongLyrics.Term.Text
return terms
}

fun mergeAndFuriganiseTerms(tokeniser: Tokenizer, terms: List<SongLyrics.Term>): List<SongLyrics.Term> {
if (terms.isEmpty()) {
return emptyList()
}

val ret: MutableList<SongLyrics.Term> = mutableListOf()
val terms_to_process: MutableList<SongLyrics.Term> = mutableListOf()

for (term in terms) {
val text = term.subterms.single().text
if (text.any { it.isJP() }) {
terms_to_process.add(term)
}
else {
ret.addAll(_mergeAndFuriganiseTerms(tokeniser, terms_to_process))
terms_to_process.clear()
ret.add(term)
}
}

ret.addAll(_mergeAndFuriganiseTerms(tokeniser, terms_to_process))

return ret
}

private fun _mergeAndFuriganiseTerms(tokeniser: Tokenizer, terms: List<SongLyrics.Term>): List<SongLyrics.Term> {
if (terms.isEmpty()) {
return emptyList()
Expand Down Expand Up @@ -131,7 +145,7 @@ private fun _mergeAndFuriganiseTerms(tokeniser: Tokenizer, terms: List<SongLyric
term_head += needed
}
else {
text += subterm.text
text += subterm.text.substring(term_head)
term_head = 0
current_term++
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.toasterofbread.spmp.api.lyrics

import androidx.compose.ui.graphics.Color
import com.toasterofbread.spmp.api.cast
import com.toasterofbread.spmp.api.lyrics.kugou.loadKugouLyrics
import com.toasterofbread.spmp.api.lyrics.kugou.searchKugouLyrics
import com.toasterofbread.spmp.model.SongLyrics
import com.toasterofbread.spmp.resources.getString

internal class KugouLyricsSource(source_idx: Int): LyricsSource(source_idx) {
override fun getReadable(): String = getString("lyrics_source_kugou")
override fun getColour(): Color = Color(0xFF50A6FB)

override suspend fun getLyrics(lyrics_id: String): Result<SongLyrics> {
val load_result = loadKugouLyrics(lyrics_id)
val lines = load_result.getOrNull() ?: return load_result.cast()

return Result.success(
SongLyrics(
LyricsReference(lyrics_id, source_idx),
SongLyrics.SyncType.LINE_SYNC,
lines
)
)
}

override suspend fun searchForLyrics(title: String, artist_name: String?): Result<List<SearchResult>> {
return searchKugouLyrics(title, artist_name)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package com.toasterofbread.spmp.api.lyrics
import com.toasterofbread.spmp.model.SongLyrics
import com.toasterofbread.spmp.model.mediaitem.Song
import androidx.compose.ui.graphics.Color
import com.toasterofbread.spmp.model.Settings
import com.toasterofbread.spmp.resources.getStringTODO
import kotlin.reflect.KClass

sealed class LyricsSource(val idx: Int) {
data class LyricsReference(val id: String, val source_idx: Int)

sealed class LyricsSource(val source_idx: Int) {
data class SearchResult(
var id: Int,
var id: String,
var name: String,
var sync_type: SongLyrics.SyncType,
var artist_name: String?,
Expand All @@ -18,12 +21,13 @@ sealed class LyricsSource(val idx: Int) {
abstract fun getReadable(): String
abstract fun getColour(): Color

abstract suspend fun getLyrics(lyrics_id: Int): Result<SongLyrics>
abstract suspend fun getLyrics(lyrics_id: String): Result<SongLyrics>
abstract suspend fun searchForLyrics(title: String, artist_name: String? = null): Result<List<SearchResult>>

companion object {
private val lyrics_sources: List<KClass<out LyricsSource>> = listOf(
PetitLyricsSource::class
PetitLyricsSource::class,
KugouLyricsSource::class
)
val SOURCE_AMOUNT: Int get() = lyrics_sources.size

Expand All @@ -32,21 +36,26 @@ sealed class LyricsSource(val idx: Int) {
val cls = lyrics_sources[source_idx]
return cls.constructors.first().call(source_idx)
}

inline fun iterateByPriority(default: Int = Settings.KEY_LYRICS_DEFAULT_SOURCE.get(), action: (LyricsSource) -> Unit) {
for (i in 0 until SOURCE_AMOUNT) {
val source = fromIdx(if (i == 0) default else if (i > default) i - 1 else i)
action(source)
}
}
}
}

suspend fun getSongLyrics(song: Song, data: Pair<Int, Int>?): Result<SongLyrics> {
suspend fun getSongLyrics(song: Song, reference: LyricsReference? = null): Result<SongLyrics> {
val title = song.title ?: return Result.failure(RuntimeException("Song has no title"))

if (data != null) {
val source = LyricsSource.fromIdx(data.second)
return source.getLyrics(data.first)
if (reference != null) {
val source = LyricsSource.fromIdx(reference.source_idx)
return source.getLyrics(reference.id)
}

var fail_result: Result<SongLyrics>? = null
for (source_idx in 0 until LyricsSource.SOURCE_AMOUNT) {
val source = LyricsSource.fromIdx(source_idx)

LyricsSource.iterateByPriority { source ->
val result: LyricsSource.SearchResult = source.searchForLyrics(title, song.artist?.title).fold(
{ results ->
if (results.isEmpty()) {
Expand All @@ -63,7 +72,7 @@ suspend fun getSongLyrics(song: Song, data: Pair<Int, Int>?): Result<SongLyrics>
}
null
}
) ?: continue
) ?: return@iterateByPriority

val lyrics_result = source.getLyrics(result.id)
if (lyrics_result.isSuccess) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ import java.util.*
private const val DATA_START = "<lyricsData>"
private const val DATA_END = "</lyricsData>"

internal class PetitLyricsSource(idx: Int): LyricsSource(idx) {
internal class PetitLyricsSource(source_idx: Int): LyricsSource(source_idx) {
override fun getReadable(): String = getString("lyrics_source_petit")
override fun getColour(): Color = Color(0xFFBD0A0F)

override suspend fun getLyrics(lyrics_id: Int): Result<SongLyrics> {
override suspend fun getLyrics(lyrics_id: String): Result<SongLyrics> {
var fail_result: Result<SongLyrics>? = null
for (sync_type in SongLyrics.SyncType.byPriority()) {
val result = getLyricsData(lyrics_id, sync_type)
val result = getLyricsData(lyrics_id.toInt(), sync_type)
if (!result.isSuccess) {
if (fail_result == null) {
fail_result = result.cast()
Expand All @@ -33,11 +33,11 @@ internal class PetitLyricsSource(idx: Int): LyricsSource(idx) {
val lyrics: List<List<SongLyrics.Term>>
if (result.data.startsWith("<wsy>")) {
lyrics = parseTimedLyrics(result.data)
return Result.success(SongLyrics(lyrics_id, idx, sync_type, lyrics))
return Result.success(SongLyrics(LyricsReference(lyrics_id, source_idx), sync_type, lyrics))
}
else {
lyrics = parseStaticLyrics(result.data)
return Result.success(SongLyrics(lyrics_id, idx, SongLyrics.SyncType.NONE, lyrics))
return Result.success(SongLyrics(LyricsReference(lyrics_id, source_idx), SongLyrics.SyncType.NONE, lyrics))
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package com.toasterofbread.spmp.api.lyrics.kugou

import com.beust.klaxon.Klaxon
import com.toasterofbread.spmp.api.Api
import com.toasterofbread.spmp.api.lyrics.createTokeniser
import com.toasterofbread.spmp.api.lyrics.mergeAndFuriganiseTerms
import com.toasterofbread.spmp.model.SongLyrics
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import java.nio.charset.Charset
import java.util.Base64

private const val START_SKIP_LINES: Int = 4

suspend fun loadKugouLyrics(hash: String): Result<List<List<SongLyrics.Term>>> =
withContext(Dispatchers.IO) { kotlin.runCatching {
val request = Request.Builder()
.url(
"https://krcs.kugou.com/search"
.toHttpUrl().newBuilder()
.addQueryParameter("ver", "1")
.addQueryParameter("man", "yes")
.addQueryParameter("client", "mobi")
.addQueryParameter("hash", hash)
.build()
)
.build()

val response = Api.request(request, is_gzip = false).getOrThrow()
val search_response: KugouHashSearchResponse = response.body!!.charStream().use { stream ->
Klaxon().parse(stream)!!
}

if (search_response.status != 200) {
return@withContext Result.failure(RuntimeException(search_response.errmsg))
}

val lyrics_data = downloadSearchCandidate(search_response.getBestCandidate()).getOrThrow()

val reverse_lines: MutableList<SongLyrics.Term> = mutableListOf()
var previous_time: Long? = null

for (line in lyrics_data.lines().asReversed()) {
if (line.length < 10 || line[0] != '[' || !line[1].isDigit()) {
continue
}

val split = line.split(']', limit = 2)
val time = parseTimeString(split[0].substring(1))

reverse_lines.add(
SongLyrics.Term(
listOf(SongLyrics.Term.Text(split[1])),
-1,
start = time,
end = previous_time ?: Long.MAX_VALUE
)
)

previous_time = time
}

for (term in reverse_lines.withIndex()) {
term.value.line_index = reverse_lines.size - term.index - 1
term.value.line_range = term.value.start!! .. term.value.end!!
}

val tokeniser = createTokeniser()
return@runCatching reverse_lines.asReversed().mapIndexedNotNull { index, line ->
if (index < START_SKIP_LINES) null
else mergeAndFuriganiseTerms(tokeniser, listOf(line))
}
}}

private fun parseTimeString(string: String): Long {
var time: Long = 0L

val split = string.split(':')
for (part in split.withIndex()) {
when (split.size - part.index - 1) {
// Seconds
0 -> time += (part.value.toFloat() * 1000L).toLong()
// Minutes
1 -> time += part.value.toLong() * 60000L
// Hours
2 -> time += part.value.toLong() * 3600000L

else -> throw NotImplementedError("Time stage not implemented: ${split.size - part.index - 1}")
}
}

return time
}

private suspend fun downloadSearchCandidate(candidate: KugouHashSearchResponse.Candidate): Result<String> =
withContext(Dispatchers.IO) { runCatching {
val request = Request.Builder()
.url(
"https://krcs.kugou.com/download"
.toHttpUrl().newBuilder()
.addQueryParameter("ver", "1")
.addQueryParameter("man", "yes")
.addQueryParameter("client", "pc")
.addQueryParameter("fmt", "lrc")
.addQueryParameter("id", candidate.id)
.addQueryParameter("accesskey", candidate.accesskey)
.build()
)
.build()

val result = Api.request(request)
val response = result.getOrThrow()

val download_response: KugouSearchCandidateDownloadResponse =
response.body!!.charStream().use { stream ->
Klaxon().parse(stream)!!
}

if (download_response.status != 200) {
return@withContext Result.failure(RuntimeException(download_response.info))
}

val bytes = Base64.getDecoder().decode(download_response.content)
return@runCatching String(bytes, Charset.forName(download_response.charset))
} }

private class KugouSearchCandidateDownloadResponse(
val status: Int,
val info: String,
val charset: String,
val content: String
)

private class KugouHashSearchResponse(
val status: Int,
val errmsg: String,
val candidates: List<Candidate>
) {
class Candidate(
val id: String,
val accesskey: String
)
fun getBestCandidate(): Candidate = candidates.first()
}
Loading

0 comments on commit 5cad48d

Please sign in to comment.