diff --git a/app/src/main/assets/changelog/v3.0.1.txt b/app/src/main/assets/changelog/v3.0.1.txt index 3dbe18e8f..676f38999 100644 --- a/app/src/main/assets/changelog/v3.0.1.txt +++ b/app/src/main/assets/changelog/v3.0.1.txt @@ -40,6 +40,7 @@ PLAYBACK AND NOTIFICATIONS - The video notification has repeat, and shuffle while a queue is playing - DeArrow shows a frame from the video when nobody has submitted a thumbnail, as DeArrow itself does, with a switch to turn that off - DeArrow titles also appear on Shorts cards +- Lyrics add lrc.red and Unison as sources, find word-by-word timing more often, and no longer give live or remixed songs the studio version's timing FIXES - Fix a paused video not playing again after the screen was off for a while diff --git a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt index 54d7bf2d8..382b10034 100644 --- a/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt +++ b/app/src/main/java/io/github/aedev/flow/data/local/PlayerPreferences.kt @@ -211,6 +211,8 @@ class PlayerPreferences( val LYRICS_PROVIDER_ENABLED_KUGOU = booleanPreferencesKey("lyrics_provider_enabled_kugou") val LYRICS_PROVIDER_ENABLED_PAXSENIX = booleanPreferencesKey("lyrics_provider_enabled_paxsenix") val LYRICS_PROVIDER_ENABLED_YOUTUBESUBTITLE = booleanPreferencesKey("lyrics_provider_enabled_youtubesubtitle") + val LYRICS_PROVIDER_ENABLED_LRCRED = booleanPreferencesKey("lyrics_provider_enabled_lrcred") + val LYRICS_PROVIDER_ENABLED_UNISON = booleanPreferencesKey("lyrics_provider_enabled_unison") val SWIPE_GESTURES_ENABLED = booleanPreferencesKey("swipe_gestures_enabled") val BRIGHTNESS_SWIPE_GESTURES_ENABLED = booleanPreferencesKey("brightness_swipe_gestures_enabled") val REMEMBER_BRIGHTNESS_ENABLED = booleanPreferencesKey("remember_brightness_enabled") @@ -3448,6 +3450,8 @@ class PlayerPreferences( "KuGou" to Keys.LYRICS_PROVIDER_ENABLED_KUGOU, "Paxsenix" to Keys.LYRICS_PROVIDER_ENABLED_PAXSENIX, "YouTubeSubtitle" to Keys.LYRICS_PROVIDER_ENABLED_YOUTUBESUBTITLE, + "LrcRed" to Keys.LYRICS_PROVIDER_ENABLED_LRCRED, + "Unison" to Keys.LYRICS_PROVIDER_ENABLED_UNISON, ) fun isLyricsProviderEnabled(providerName: String): Flow = diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/BetterLyrics.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/BetterLyrics.kt index 2e9023f47..df21789dd 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/BetterLyrics.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/BetterLyrics.kt @@ -7,33 +7,23 @@ package io.github.aedev.flow.data.lyrics import android.util.Log import com.google.gson.Gson import com.google.gson.annotations.SerializedName -import io.github.aedev.flow.network.AppProxyManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import java.net.URLEncoder -import java.util.concurrent.TimeUnit /** * Service for fetching lyrics from the BetterLyrics API (lyrics-api.boidu.dev). * Returns structured LyricsEntry objects with word-level timestamps from TTML. */ -object BetterLyrics { - private const val TAG = "BetterLyrics" - - private val client: OkHttpClient - get() = AppProxyManager.applyTo(OkHttpClient.Builder()) - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .build() - +class BetterLyrics( + private val client: OkHttpClient, +) { private val gson = Gson() - private const val BASE_URL = "https://lyrics-api.boidu.dev" - private data class TTMLResponse( - @SerializedName("ttml") val ttml: String? = null + @SerializedName("ttml") val ttml: String? = null, ) /** @@ -48,73 +38,88 @@ object BetterLyrics { title: String, artist: String, duration: Int, - album: String? = null - ): Result> = withContext(Dispatchers.IO) { - try { - val encodedTitle = URLEncoder.encode(title, "UTF-8") - val encodedArtist = URLEncoder.encode(artist, "UTF-8") - - val url = buildString { - append("$BASE_URL/getLyrics") - append("?s=$encodedTitle") - append("&a=$encodedArtist") - if (!album.isNullOrBlank()) { - append("&al=${URLEncoder.encode(album, "UTF-8")}") - } - if (duration > 0) { - append("&d=$duration") + album: String? = null, + ): Result> = + withContext(Dispatchers.IO) { + try { + val encodedTitle = URLEncoder.encode(title, "UTF-8") + val encodedArtist = URLEncoder.encode(artist, "UTF-8") + + val url = + buildString { + append("$BASE_URL/getLyrics") + append("?s=$encodedTitle") + append("&a=$encodedArtist") + if (!album.isNullOrBlank()) { + append("&al=${URLEncoder.encode(album, "UTF-8")}") + } + if (duration > 0) { + append("&d=$duration") + } + } + + Log.d(TAG, "Fetching: $url") + + val request = + Request + .Builder() + .url(url) + .header( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", + ).header("Accept", "application/json") + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + // Songs nobody has looked up yet need an API key (401). That is a miss for + // this song, not a refused client, so it must not bench the provider. + val uncached = response.code == 401 && response.body.string().contains("Uncached", ignoreCase = true) + val reason = if (uncached) "no cached lyrics for this song" else "API returned ${response.code}" + return@withContext Result.failure(Exception("BetterLyrics $reason")) + } + + val body = response.body?.string() + if (body.isNullOrBlank()) { + return@withContext Result.failure(Exception("Empty response from BetterLyrics")) + } + + val ttml = + try { + val ttmlResponse = gson.fromJson(body, TTMLResponse::class.java) + ttmlResponse?.ttml + } catch (e: Exception) { + Log.d(TAG, "JSON parse failed, trying raw TTML") + if (body.contains(" - if (!response.isSuccessful) { - return@withContext Result.failure( - Exception("BetterLyrics API returned ${response.code}") - ) - } - - val body = response.body?.string() - if (body.isNullOrBlank()) { - return@withContext Result.failure(Exception("Empty response from BetterLyrics")) - } - - val ttml = try { - val ttmlResponse = gson.fromJson(body, TTMLResponse::class.java) - ttmlResponse?.ttml - } catch (e: Exception) { - Log.d(TAG, "JSON parse failed, trying raw TTML") - if (body.contains("> { - return BetterLyrics.getLyrics(title, artist, duration, album) - } + album: String?, + ): Result> = betterLyrics.getLyrics(title, artist, duration, album) } diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/InFlightRuns.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/InFlightRuns.kt new file mode 100644 index 000000000..0bb23b565 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/InFlightRuns.kt @@ -0,0 +1,45 @@ +package io.github.aedev.flow.data.lyrics + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async + +/** + * One run per key. A caller asking for a key that is already running waits for that run instead + * of starting another; the run is cancelled only once every caller waiting on it has gone. + */ +internal class InFlightRuns( + private val scope: CoroutineScope, +) { + private class Run( + val result: Deferred, + ) { + var waiters = 0 + } + + private val runs = HashMap>() + + suspend fun join( + key: K, + block: suspend () -> V, + ): V { + val run = + synchronized(runs) { + val running = + runs[key]?.takeIf { it.result.isActive } + ?: Run(scope.async { block() }).also { started -> + runs[key] = started + started.result.invokeOnCompletion { + synchronized(runs) { if (runs[key] === started) runs.remove(key) } + } + } + running.also { it.waiters++ } + } + try { + return run.result.await() + } finally { + val abandoned = synchronized(runs) { --run.waiters == 0 && run.result.isActive } + if (abandoned) run.result.cancel() + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/KuGouLyricsProvider.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/KuGouLyricsProvider.kt index 08dae2a89..312fbc932 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/KuGouLyricsProvider.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/KuGouLyricsProvider.kt @@ -1,26 +1,32 @@ -//================================================================================================== -//This implementation was based on metrolist's (https://github.com/MetrolistGroup/Metrolist) -//================================================================================================== +// ================================================================================================== +// This implementation was based on metrolist's (https://github.com/MetrolistGroup/Metrolist) +// ================================================================================================== package io.github.aedev.flow.data.lyrics import io.github.aedev.flow.data.lyrics.kugou.KuGou import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient -class KuGouLyricsProvider : LyricsProvider { +class KuGouLyricsProvider( + client: OkHttpClient, +) : LyricsProvider { override val name = "KuGou" + private val kuGou = KuGou(client) + override suspend fun getLyrics( id: String, title: String, artist: String, duration: Int, - album: String? - ): Result> = withContext(Dispatchers.IO) { - runCatching { - val lrc = KuGou.getLyrics(title, artist, duration, album).getOrThrow() - LyricsUtils.parseLyrics(lrc) + album: String?, + ): Result> = + withContext(Dispatchers.IO) { + runCatching { + val lrc = kuGou.getLyrics(title, artist, duration, album).getOrThrow() + LyricsUtils.parseLyrics(lrc) + } } - } } diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/LrcLibLyricsProvider.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/LrcLibLyricsProvider.kt index 81bca8b20..33f4e9b9c 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/LrcLibLyricsProvider.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/LrcLibLyricsProvider.kt @@ -1,11 +1,10 @@ package io.github.aedev.flow.data.lyrics -import io.github.aedev.flow.network.AppProxyManager +import com.google.gson.Gson import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import com.google.gson.Gson import java.net.URLEncoder import java.util.concurrent.TimeUnit @@ -13,24 +12,26 @@ import java.util.concurrent.TimeUnit * LyricsProvider implementation wrapping the LrcLib API. * LrcLib provides standard LRC synced lyrics (line-level only, no word sync). */ -class LrcLibLyricsProvider : LyricsProvider { +class LrcLibLyricsProvider( + client: OkHttpClient, +) : LyricsProvider { override val name = "LrcLib" - private val client: OkHttpClient - get() = AppProxyManager.applyTo( - OkHttpClient.Builder() - .connectTimeout(3, TimeUnit.SECONDS) - .readTimeout(4, TimeUnit.SECONDS) - .callTimeout(5, TimeUnit.SECONDS) - ).build() + private val client = + client + .newBuilder() + .connectTimeout(3, TimeUnit.SECONDS) + .readTimeout(4, TimeUnit.SECONDS) + .callTimeout(5, TimeUnit.SECONDS) + .build() private val gson = Gson() - + private data class LrcLibResponse( val id: Long? = null, val plainLyrics: String?, val syncedLyrics: String?, val instrumental: Boolean?, - val duration: Double? = null + val duration: Double? = null, ) override suspend fun getLyrics( @@ -38,52 +39,59 @@ class LrcLibLyricsProvider : LyricsProvider { title: String, artist: String, duration: Int, - album: String? - ): Result> = withContext(Dispatchers.IO) { - try { - val result = tryFetch(artist, title, duration) - ?: trySearch(artist, title, duration) - ?: tryFetch("", title, duration) - ?: trySearch("", "$artist $title", duration) - - if (result != null) { - val syncedLrc = result.syncedLyrics - val plainLrc = result.plainLyrics - - if (syncedLrc != null) { - val entries = LyricsUtils.parseLyrics(syncedLrc) - if (entries.isNotEmpty()) { + album: String?, + ): Result> = + withContext(Dispatchers.IO) { + try { + val result = + tryFetch(artist, title, duration) + ?: trySearch(artist, title, duration) + ?: tryFetch("", title, duration) + ?: trySearch("", "$artist $title", duration) + + if (result != null) { + val syncedLrc = result.syncedLyrics + val plainLrc = result.plainLyrics + + if (syncedLrc != null) { + val entries = LyricsUtils.parseLyrics(syncedLrc) + if (entries.isNotEmpty()) { + return@withContext Result.success(entries) + } + } + + if (plainLrc != null) { + // Plain text: single entry with no sync + val entries = listOf(LyricsEntry(time = 0L, text = plainLrc)) return@withContext Result.success(entries) } + + Result.failure(Exception("No lyrics content in response")) + } else { + Result.failure(Exception("No lyrics found on LrcLib")) } - - if (plainLrc != null) { - // Plain text: single entry with no sync - val entries = listOf(LyricsEntry(time = 0L, text = plainLrc)) - return@withContext Result.success(entries) - } - - Result.failure(Exception("No lyrics content in response")) - } else { - Result.failure(Exception("No lyrics found on LrcLib")) + } catch (e: Exception) { + Result.failure(e) } - } catch (e: Exception) { - Result.failure(e) } - } - - private fun tryFetch(artist: String, title: String, duration: Int?): LrcLibResponse? { + + private fun tryFetch( + artist: String, + title: String, + duration: Int?, + ): LrcLibResponse? { try { if (artist.isEmpty()) return null - + val encodedArtist = URLEncoder.encode(artist, "UTF-8") val encodedTitle = URLEncoder.encode(title, "UTF-8") - - val url = if (duration != null && duration > 0) { - "https://lrclib.net/api/get?artist_name=$encodedArtist&track_name=$encodedTitle&duration=$duration" - } else { - "https://lrclib.net/api/get?artist_name=$encodedArtist&track_name=$encodedTitle" - } + + val url = + if (duration != null && duration > 0) { + "https://lrclib.net/api/get?artist_name=$encodedArtist&track_name=$encodedTitle&duration=$duration" + } else { + "https://lrclib.net/api/get?artist_name=$encodedArtist&track_name=$encodedTitle" + } val request = Request.Builder().url(url).build() client.newCall(request).execute().use { response -> @@ -97,30 +105,35 @@ class LrcLibLyricsProvider : LyricsProvider { } return null } - - private fun trySearch(artist: String, title: String, duration: Int?): LrcLibResponse? { + + private fun trySearch( + artist: String, + title: String, + duration: Int?, + ): LrcLibResponse? { try { val q = if (artist.isNotEmpty()) "$artist $title" else title val encodedQ = URLEncoder.encode(q, "UTF-8") val url = "https://lrclib.net/api/search?q=$encodedQ" - + val request = Request.Builder().url(url).build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) return null val body = response.body?.string() ?: return null val results = gson.fromJson(body, Array::class.java) - + if (results.isNotEmpty()) { if (duration != null && duration > 0) { - val closestSynced = results - .filter { it.syncedLyrics != null } - .minByOrNull { Math.abs((it.duration ?: 0.0) - duration) } - + val closestSynced = + results + .filter { it.syncedLyrics != null } + .minByOrNull { Math.abs((it.duration ?: 0.0) - duration) } + if (closestSynced != null && Math.abs((closestSynced.duration ?: 0.0) - duration) < 5) { return closestSynced } } - + return results.firstOrNull { it.syncedLyrics != null } ?: results[0] } } diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/LrcRedLyricsProvider.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/LrcRedLyricsProvider.kt new file mode 100644 index 000000000..2a4010580 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/LrcRedLyricsProvider.kt @@ -0,0 +1,106 @@ +package io.github.aedev.flow.data.lyrics + +import io.github.aedev.flow.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * lrc.red: Apple Music word-synced TTML archived by ISRC. Its search names the recording behind + * each hit, and the hit is checked against the request with [LyricsMatching] before its document + * is fetched. `lyrics-api.binimum.org`, which LyricsPlus used to call first, now redirects here. + */ +class LrcRedLyricsProvider( + private val client: OkHttpClient, +) : LyricsProvider { + override val name = NAME + + @Serializable + internal data class Hit( + @SerialName("track_name") val trackName: String? = null, + @SerialName("artist_name") val artistName: String? = null, + val duration: Int? = null, + @SerialName("timing_type") val timingType: String? = null, + val lyricsUrl: String? = null, + ) + + @Serializable + private data class SearchResponse( + val results: List = emptyList(), + ) + + override suspend fun getLyrics( + id: String, + title: String, + artist: String, + duration: Int, + album: String?, + ): Result> = + withContext(Dispatchers.IO) { + runCatching { + val request = LyricsMatching.Song(title, artist, duration) + val shaped = + buildList { + add("track" to title) + if (artist.isNotBlank()) add("artist" to artist) + if (!album.isNullOrBlank()) add("album" to album) + if (duration > 0) add("duration" to duration.toString()) + } + val hit = + pick(search(shaped), request) + ?: pick(search(listOf("q" to if (artist.isBlank()) title else "$artist - $title")), request) + ?: error("No lrc.red match") + val document = get(hit.lyricsUrl!!.toHttpUrl()) ?: error("lrc.red document unavailable") + TTMLParser.parseTTMLToLyricsEntries(document).ifEmpty { error("lrc.red document has no lines") } + } + } + + private fun search(params: List>): List { + val url = + SEARCH_URL + .toHttpUrl() + .newBuilder() + .apply { params.forEach { (key, value) -> addQueryParameter(key, value) } } + .build() + return get(url)?.let(::parseSearch).orEmpty() + } + + private fun get(url: HttpUrl): String? { + val request = + Request + .Builder() + .url(url) + .header("User-Agent", "Flow/${BuildConfig.VERSION_NAME}") + .build() + return client.newCall(request).execute().use { response -> + if (response.isSuccessful) response.body.string().takeIf { it.isNotBlank() } else null + } + } + + internal companion object { + const val NAME = "LrcRed" + private const val SEARCH_URL = "https://lrc.red/api/v1" + private val json = Json { ignoreUnknownKeys = true } + + fun parseSearch(body: String): List = json.decodeFromString(body).results + + /** The same recording, word-timed files first; documents are only fetched over https. */ + fun pick( + hits: List, + request: LyricsMatching.Song, + ): Hit? = + LyricsMatching.best( + hits.filter { it.lyricsUrl?.toHttpUrlOrNull()?.isHttps == true }, + request, + describe = { LyricsMatching.Song(it.trackName.orEmpty(), it.artistName.orEmpty(), it.duration ?: 0) }, + preferred = { it.timingType.equals("word", ignoreCase = true) }, + ) + } +} diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsHelper.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsHelper.kt index 9112c49d5..931c3968d 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsHelper.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsHelper.kt @@ -6,337 +6,392 @@ package io.github.aedev.flow.data.lyrics import android.content.Context import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext import io.github.aedev.flow.data.local.PlayerPreferences +import io.github.aedev.flow.di.MetadataClient import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient +import java.util.Collections +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LyricsHelper + @Inject + constructor( + @ApplicationContext private val context: Context, + @MetadataClient client: OkHttpClient, + ) { + companion object { + private const val TAG = "LyricsHelper" + private const val PER_PROVIDER_TIMEOUT_MS = 8_000L + private const val MAX_TOTAL_TIMEOUT_MS = 25_000L + private const val PROVIDER_COOLDOWN_MS = 10 * 60 * 1000L + + // After a line-synced hit, the providers after it get this long in total to find word sync. + private const val WORD_SYNC_GRACE_MS = 4_000L + private const val MEMORY_CACHE_SIZE = 32 + } -class LyricsHelper( - private val context: Context, -) { - companion object { - private const val TAG = "LyricsHelper" - private const val PER_PROVIDER_TIMEOUT_MS = 8_000L - private const val MAX_TOTAL_TIMEOUT_MS = 25_000L - private const val PROVIDER_COOLDOWN_MS = 10 * 60 * 1000L - - // After a line-synced hit, the providers after it get this long in total to find word sync. - private const val WORD_SYNC_GRACE_MS = 4_000L - } + // Bounded per call too: a provider's blocking request would otherwise outlive its timeout. + private val registry = + LyricsProviderRegistry.create(client.newBuilder().callTimeout(PER_PROVIDER_TIMEOUT_MS, TimeUnit.MILLISECONDS).build()) + private val playerPreferences = PlayerPreferences(context) + private val cache: MutableMap> = + Collections.synchronizedMap( + object : LinkedHashMap>(MEMORY_CACHE_SIZE, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry>) = size > MEMORY_CACHE_SIZE + }, + ) - private val registry = LyricsProviderRegistry.default() - private val playerPreferences = PlayerPreferences(context) - private val cache = mutableMapOf>() + // The player, the music service and downloads all ask for the track that just started; they + // share one provider run per video instead of each running the whole chain. + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val inFlight = InFlightRuns, String>?>(ioScope) - private val providerCooldowns = java.util.concurrent.ConcurrentHashMap() + private val providerCooldowns = java.util.concurrent.ConcurrentHashMap() - suspend fun forceRefresh( - videoId: String, - ctx: Context? = null, - ) { - cache.remove(videoId) - try { - LyricsCacheManager.evictLyrics(ctx ?: context, videoId) - } catch (e: Exception) { - Log.w(TAG, "Disk cache evict failed: ${e.message}") + suspend fun forceRefresh( + videoId: String, + ctx: Context? = null, + ) { + cache.remove(videoId) + try { + LyricsCacheManager.evictLyrics(ctx ?: context, videoId) + } catch (e: Exception) { + Log.w(TAG, "Disk cache evict failed: ${e.message}") + } + providerCooldowns.clear() + Log.d(TAG, "Force refresh requested for $videoId — caches and cooldowns cleared") } - providerCooldowns.clear() - Log.d(TAG, "Force refresh requested for $videoId — caches and cooldowns cleared") - } - suspend fun getLyrics( - videoId: String, - title: String, - artist: String, - duration: Int, - album: String? = null, - ctx: Context? = null, - ): Pair, String>? { - val targetContext = ctx ?: context - - cache[videoId]?.let { cached -> - val normalized = normalizeEntries(cached) - if (normalized.none { !it.words.isNullOrEmpty() } || hasWordSync(normalized)) { - Log.d(TAG, "Returning in-memory cached lyrics for $videoId") - return normalized to "MemoryCache" + suspend fun getLyrics( + videoId: String, + title: String, + artist: String, + duration: Int, + album: String? = null, + ctx: Context? = null, + ): Pair, String>? = inFlight.join(videoId) { fetchLyrics(videoId, title, artist, duration, album, ctx) } + + private suspend fun fetchLyrics( + videoId: String, + title: String, + artist: String, + duration: Int, + album: String?, + ctx: Context?, + ): Pair, String>? { + val targetContext = ctx ?: context + + cache[videoId]?.let { cached -> + val normalized = normalizeEntries(cached) + if (normalized.none { !it.words.isNullOrEmpty() } || hasWordSync(normalized)) { + Log.d(TAG, "Returning in-memory cached lyrics for $videoId") + return normalized to "MemoryCache" + } + Log.d(TAG, "Ignoring weak in-memory word-sync cache for $videoId") + cache.remove(videoId) } - Log.d(TAG, "Ignoring weak in-memory word-sync cache for $videoId") - cache.remove(videoId) - } - var cachedLineFallback: Pair, String>? = null - val diskCached = LyricsCacheManager.getLyrics(targetContext, videoId) - val normalizedDiskCached = diskCached?.let { normalizeEntries(it.sorted()) } - if (!normalizedDiskCached.isNullOrEmpty() && hasWordSync(normalizedDiskCached) && - hasReasonableTimestamps(normalizedDiskCached, duration) - ) { - cache[videoId] = normalizedDiskCached - return normalizedDiskCached to "DiskCache" - } else if (!normalizedDiskCached.isNullOrEmpty() && hasReasonableTimestamps(normalizedDiskCached, duration)) { - cachedLineFallback = stripWordTimings(normalizedDiskCached) to "DiskCache" - Log.d(TAG, "Disk cache has line/plain lyrics only; trying to upgrade to word-sync") - } else if (!diskCached.isNullOrEmpty()) { - Log.d(TAG, "Disk cache has invalid timestamps for $videoId, discarding") - LyricsCacheManager.evictLyrics(targetContext, videoId) - } + var cachedLineFallback: Pair, String>? = null + val diskCached = LyricsCacheManager.getLyrics(targetContext, videoId) + val normalizedDiskCached = diskCached?.let { normalizeEntries(it.sorted()) } + if (!normalizedDiskCached.isNullOrEmpty() && hasWordSync(normalizedDiskCached) && + hasReasonableTimestamps(normalizedDiskCached, duration) + ) { + cache[videoId] = normalizedDiskCached + return normalizedDiskCached to "DiskCache" + } else if (!normalizedDiskCached.isNullOrEmpty() && hasReasonableTimestamps(normalizedDiskCached, duration)) { + cachedLineFallback = stripWordTimings(normalizedDiskCached) to "DiskCache" + Log.d(TAG, "Disk cache has line/plain lyrics only; trying to upgrade to word-sync") + } else if (!diskCached.isNullOrEmpty()) { + Log.d(TAG, "Disk cache has invalid timestamps for $videoId, discarding") + LyricsCacheManager.evictLyrics(targetContext, videoId) + } - val cleanedTitle = LyricsUtils.cleanTitle(title) - val cleanedArtist = LyricsUtils.cleanArtist(artist) - Log.d(TAG, "Cache miss. Querying providers: cleanedTitle=\"$cleanedTitle\", cleanedArtist=\"$cleanedArtist\"") + val cleanedTitle = LyricsMatching.cleanTitle(title) + val cleanedArtist = LyricsMatching.cleanArtist(artist) + Log.d(TAG, "Cache miss. Querying providers: cleanedTitle=\"$cleanedTitle\", cleanedArtist=\"$cleanedArtist\"") - val orderString = playerPreferences.lyricsProviderOrder.first() - val enabledStates = playerPreferences.allLyricsProviderEnabledStates().first() - val orderedProviders = - registry - .getOrderedProviders(orderString) - .filter { enabledStates[it.name] != false } + val orderString = playerPreferences.lyricsProviderOrder.first() + val enabledStates = playerPreferences.allLyricsProviderEnabledStates().first() + val orderedProviders = + registry + .getOrderedProviders(orderString) + .filter { enabledStates[it.name] != false } - Log.d(TAG, "Enabled providers in order: ${orderedProviders.joinToString { it.name }}") + Log.d(TAG, "Enabled providers in order: ${orderedProviders.joinToString { it.name }}") - val now = System.currentTimeMillis() + val now = System.currentTimeMillis() - var unsyncedFallback: Pair, String>? = null - var lineSyncedFallback: Pair, String>? = null - var wordSyncDeadline = Long.MAX_VALUE + var unsyncedFallback: Pair, String>? = null + var lineSyncedFallback: Pair, String>? = null + var wordSyncDeadline = Long.MAX_VALUE - val syncedResult = - withTimeoutOrNull(MAX_TOTAL_TIMEOUT_MS) { - for (provider in orderedProviders) { - val cooldownUntil = providerCooldowns[provider.name] - if (cooldownUntil != null && cooldownUntil > now) { - Log.d(TAG, "Skipping ${provider.name} (auth cooldown for ${(cooldownUntil - now) / 1000}s)") - continue - } - - val timeout = minOf(PER_PROVIDER_TIMEOUT_MS, wordSyncDeadline - System.currentTimeMillis()) - if (timeout <= 0L) break + val syncedResult = + withTimeoutOrNull(MAX_TOTAL_TIMEOUT_MS) { + for (provider in orderedProviders) { + val cooldownUntil = providerCooldowns[provider.name] + if (cooldownUntil != null && cooldownUntil > now) { + Log.d(TAG, "Skipping ${provider.name} (auth cooldown for ${(cooldownUntil - now) / 1000}s)") + continue + } - Log.d(TAG, "Trying provider: ${provider.name}") - val providerResult = - try { - withTimeoutOrNull(timeout) { - provider.getLyrics(videoId, cleanedTitle, cleanedArtist, duration, album) + val timeout = minOf(PER_PROVIDER_TIMEOUT_MS, wordSyncDeadline - System.currentTimeMillis()) + if (timeout <= 0L) break + + Log.d(TAG, "Trying provider: ${provider.name}") + val providerResult = + try { + callProvider(provider, timeout, videoId, cleanedTitle, cleanedArtist, duration, album) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "${provider.name} threw: ${e.message}") + null } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "${provider.name} threw: ${e.message}") - null - } - if (providerResult != null && providerResult.isSuccess) { - var entries = providerResult.getOrNull() - if (!entries.isNullOrEmpty()) { - entries = LyricsUtils.filterCreditLines(normalizeEntries(entries.sorted())) - if (entries.isNotEmpty() && hasReasonableTimestamps(entries, duration)) { - if (entriesAreSynced(entries) && hasWordSync(entries)) { - Log.d(TAG, "Got ${entries.size} WORD-SYNCED lines from ${provider.name} — using these") - return@withTimeoutOrNull entries to provider.name - } else if (entriesAreSynced(entries)) { - if (lineSyncedFallback == null) { + if (providerResult != null && providerResult.isSuccess) { + var entries = providerResult.getOrNull() + if (!entries.isNullOrEmpty()) { + entries = LyricsUtils.filterCreditLines(normalizeEntries(entries.sorted())) + if (entries.isNotEmpty() && hasReasonableTimestamps(entries, duration)) { + if (entriesAreSynced(entries) && hasWordSync(entries)) { + Log.d(TAG, "Got ${entries.size} WORD-SYNCED lines from ${provider.name} — using these") + return@withTimeoutOrNull entries to provider.name + } else if (entriesAreSynced(entries)) { + if (lineSyncedFallback == null) { + Log.d( + TAG, + "${provider.name} returned LINE-SYNCED lines — keeping them, looking briefly for word sync", + ) + lineSyncedFallback = stripWordTimings(entries) to provider.name + wordSyncDeadline = System.currentTimeMillis() + WORD_SYNC_GRACE_MS + } + } else if (unsyncedFallback == null) { Log.d( TAG, - "${provider.name} returned LINE-SYNCED lines — keeping them, looking briefly for word sync", + "${provider.name} returned ${entries.size} UNSYNCED lines — saving as fallback, continuing for synced", ) - lineSyncedFallback = stripWordTimings(entries) to provider.name - wordSyncDeadline = System.currentTimeMillis() + WORD_SYNC_GRACE_MS + unsyncedFallback = entries to provider.name + } else { + Log.d(TAG, "${provider.name} returned unsynced; already have a fallback, continuing for synced") } - } else if (unsyncedFallback == null) { - Log.d( - TAG, - "${provider.name} returned ${entries.size} UNSYNCED lines — saving as fallback, continuing for synced", - ) - unsyncedFallback = entries to provider.name - } else { - Log.d(TAG, "${provider.name} returned unsynced; already have a fallback, continuing for synced") + } else if (entries.isNotEmpty()) { + Log.w(TAG, "${provider.name} returned lyrics with unreasonable timestamps, skipping") } - } else if (entries.isNotEmpty()) { - Log.w(TAG, "${provider.name} returned lyrics with unreasonable timestamps, skipping") } - } - } else { - val errorMsg = providerResult?.exceptionOrNull()?.message ?: "timeout or exception" - if (isAuthFailure(errorMsg)) { - providerCooldowns[provider.name] = now + PROVIDER_COOLDOWN_MS - Log.w(TAG, "${provider.name} auth failure ($errorMsg) — cooling down for 10 min") } else { - Log.w(TAG, "${provider.name} failed: $errorMsg") + val errorMsg = providerResult?.exceptionOrNull()?.message ?: "timeout or exception" + if (isAuthFailure(errorMsg)) { + providerCooldowns[provider.name] = now + PROVIDER_COOLDOWN_MS + Log.w(TAG, "${provider.name} auth failure ($errorMsg) — cooling down for 10 min") + } else { + Log.w(TAG, "${provider.name} failed: $errorMsg") + } } } + null + } ?: lineSyncedFallback + + if (syncedResult != null) { + cache[videoId] = syncedResult.first + try { + LyricsCacheManager.saveLyrics(targetContext, videoId, syncedResult.first) + } catch (e: Exception) { + Log.w(TAG, "Disk cache save failed: ${e.message}") } - null - } ?: lineSyncedFallback + return syncedResult + } - if (syncedResult != null) { - cache[videoId] = syncedResult.first - try { - LyricsCacheManager.saveLyrics(targetContext, videoId, syncedResult.first) - } catch (e: Exception) { - Log.w(TAG, "Disk cache save failed: ${e.message}") + unsyncedFallback?.let { fallback -> + Log.d(TAG, "No synced lyrics across providers — falling back to unsynced from ${fallback.second}") + cache[videoId] = fallback.first + try { + LyricsCacheManager.saveLyrics(targetContext, videoId, fallback.first) + } catch (e: Exception) { + Log.w(TAG, "Disk cache save failed: ${e.message}") + } + return fallback } - return syncedResult - } - unsyncedFallback?.let { fallback -> - Log.d(TAG, "No synced lyrics across providers — falling back to unsynced from ${fallback.second}") - cache[videoId] = fallback.first - try { - LyricsCacheManager.saveLyrics(targetContext, videoId, fallback.first) - } catch (e: Exception) { - Log.w(TAG, "Disk cache save failed: ${e.message}") + cachedLineFallback?.let { + cache[videoId] = it.first + return it } - return fallback - } - cachedLineFallback?.let { - cache[videoId] = it.first - return it + Log.w(TAG, "No lyrics found for $videoId") + return null } - Log.w(TAG, "No lyrics found for $videoId") - return null - } - - private fun hasWordSync(entries: List): Boolean { - val wordLines = entries.filter { !it.words.isNullOrEmpty() } - if (wordLines.isEmpty()) return false - - val lineCount = entries.size.coerceAtLeast(1) - val ratio = wordLines.size.toFloat() / lineCount - if (lineCount > 5 && wordLines.size < 3) return false - if (lineCount > 10 && ratio < 0.25f) return false - - val validTimingLines = - wordLines.count { entry -> - val words = entry.words.orEmpty().sortedBy { it.startTime } - words.isNotEmpty() && - words.last().endTime > words.first().startTime && - words.zipWithNext().all { (current, next) -> - current.endTime >= current.startTime && next.startTime >= current.startTime - } + /** + * Providers make blocking requests that ignore cancellation, so a timeout around the call + * itself would still wait for the request. Awaiting it as a separate job lets the chain move + * on at the deadline; the abandoned request ends on the client's call timeout. + */ + private suspend fun callProvider( + provider: LyricsProvider, + timeoutMs: Long, + videoId: String, + title: String, + artist: String, + duration: Int, + album: String?, + ): Result>? { + val call = ioScope.async { provider.getLyrics(videoId, title, artist, duration, album) } + return try { + withTimeoutOrNull(timeoutMs) { call.await() } + } finally { + call.cancel() } - if (validTimingLines < minOf(2, wordLines.size)) return false - - val firstWordMs = wordLines.minOf { it.words!!.first().startTime } - val lastWordMs = wordLines.maxOf { it.words!!.last().endTime } - if (lineCount > 5 && lastWordMs - firstWordMs < 10_000L) return false + } - return true - } + private fun hasWordSync(entries: List): Boolean { + val wordLines = entries.filter { !it.words.isNullOrEmpty() } + if (wordLines.isEmpty()) return false + + val lineCount = entries.size.coerceAtLeast(1) + val ratio = wordLines.size.toFloat() / lineCount + if (lineCount > 5 && wordLines.size < 3) return false + if (lineCount > 10 && ratio < 0.25f) return false + + val validTimingLines = + wordLines.count { entry -> + val words = entry.words.orEmpty().sortedBy { it.startTime } + words.isNotEmpty() && + words.last().endTime > words.first().startTime && + words.zipWithNext().all { (current, next) -> + current.endTime >= current.startTime && next.startTime >= current.startTime + } + } + if (validTimingLines < minOf(2, wordLines.size)) return false - private fun hasReasonableTimestamps( - entries: List, - durationSec: Int, - ): Boolean { - val timed = entries.filter { it.time > 0 } - if (timed.size < 2) return true - val firstMs = timed.first().time - val lastMs = timed.last().time - if (lastMs - firstMs < 10_000L) return false - if (durationSec > 0 && firstMs > durationSec * 1000L + 10_000L) return false - return true - } + val firstWordMs = wordLines.minOf { it.words!!.first().startTime } + val lastWordMs = wordLines.maxOf { it.words!!.last().endTime } + if (lineCount > 5 && lastWordMs - firstWordMs < 10_000L) return false - private fun isAuthFailure(message: String?): Boolean { - if (message.isNullOrBlank()) return false - return message.contains(" 401") || message.contains(" 403") || - message.contains("Unauthorized", ignoreCase = true) || - message.contains("Forbidden", ignoreCase = true) - } + return true + } - fun entriesAreSynced(entries: List): Boolean { - if (entries.size < 2) return false - val main = entries.filter { !it.isBackground } - val list = if (main.size >= 2) main else entries - val times = list.map { it.time }.distinct() - if (times.size < 2) return false - val firstPositive = times.firstOrNull { it > 0L } ?: return false - val maxTime = list.maxOf { it.time } - if (maxTime - firstPositive < 5_000L) return false - val hasWordTimings = entries.any { !it.words.isNullOrEmpty() } - if (hasWordTimings) return true - val distinctTimedLines = list.count { it.time > 0L } - return distinctTimedLines >= (list.size * 0.5).toInt().coerceAtLeast(2) - } + private fun hasReasonableTimestamps( + entries: List, + durationSec: Int, + ): Boolean { + val timed = entries.filter { it.time > 0 } + if (timed.size < 2) return true + val firstMs = timed.first().time + val lastMs = timed.last().time + if (lastMs - firstMs < 10_000L) return false + if (durationSec > 0 && firstMs > durationSec * 1000L + 10_000L) return false + return true + } - private fun stripWordTimings(entries: List): List = entries.map { entry -> entry.copy(words = null) } + private fun isAuthFailure(message: String?): Boolean { + if (message.isNullOrBlank()) return false + return message.contains(" 401") || message.contains(" 403") || + message.contains("Unauthorized", ignoreCase = true) || + message.contains("Forbidden", ignoreCase = true) + } - private fun normalizeEntries(entries: List): List = - entries.map { entry -> - entry.copy( - text = LyricsUtils.decodeHtmlEntities(entry.text), - words = - entry.words?.map { word -> - word.copy(text = LyricsUtils.decodeHtmlEntities(word.text)) - }, - ) + fun entriesAreSynced(entries: List): Boolean { + if (entries.size < 2) return false + val main = entries.filter { !it.isBackground } + val list = if (main.size >= 2) main else entries + val times = list.map { it.time }.distinct() + if (times.size < 2) return false + val firstPositive = times.firstOrNull { it > 0L } ?: return false + val maxTime = list.maxOf { it.time } + if (maxTime - firstPositive < 5_000L) return false + val hasWordTimings = entries.any { !it.words.isNullOrEmpty() } + if (hasWordTimings) return true + val distinctTimedLines = list.count { it.time > 0L } + return distinctTimedLines >= (list.size * 0.5).toInt().coerceAtLeast(2) } - fun clearCache(videoId: String? = null) { - if (videoId != null) cache.remove(videoId) else cache.clear() - } + private fun stripWordTimings(entries: List): List = entries.map { entry -> entry.copy(words = null) } + + private fun normalizeEntries(entries: List): List = + entries.map { entry -> + entry.copy( + text = LyricsUtils.decodeHtmlEntities(entry.text), + words = + entry.words?.map { word -> + word.copy(text = LyricsUtils.decodeHtmlEntities(word.text)) + }, + ) + } - /** - * Streams a result from every enabled provider (in the user's configured order) so the UI can - * offer alternatives when the automatic pick is wrong. Unlike [getLyrics] it never stops at - * the first synced hit and never touches the caches. - */ - suspend fun getAllLyrics( - videoId: String, - title: String, - artist: String, - duration: Int, - album: String? = null, - onCandidate: suspend (LyricsCandidate) -> Unit, - ) { - val cleanedTitle = LyricsUtils.cleanTitle(title) - val cleanedArtist = LyricsUtils.cleanArtist(artist) - val orderString = playerPreferences.lyricsProviderOrder.first() - val enabledStates = playerPreferences.allLyricsProviderEnabledStates().first() - val orderedProviders = - registry - .getOrderedProviders(orderString) - .filter { enabledStates[it.name] != false } - for (provider in orderedProviders) { - val providerResult = - try { - withTimeoutOrNull(PER_PROVIDER_TIMEOUT_MS) { - provider.getLyrics(videoId, cleanedTitle, cleanedArtist, duration, album) + fun clearCache(videoId: String? = null) { + if (videoId != null) cache.remove(videoId) else cache.clear() + } + + /** + * Streams a result from every enabled provider (in the user's configured order) so the UI can + * offer alternatives when the automatic pick is wrong. Unlike [getLyrics] it never stops at + * the first synced hit and never touches the caches. + */ + suspend fun getAllLyrics( + videoId: String, + title: String, + artist: String, + duration: Int, + album: String? = null, + onCandidate: suspend (LyricsCandidate) -> Unit, + ) { + val cleanedTitle = LyricsMatching.cleanTitle(title) + val cleanedArtist = LyricsMatching.cleanArtist(artist) + val orderString = playerPreferences.lyricsProviderOrder.first() + val enabledStates = playerPreferences.allLyricsProviderEnabledStates().first() + val orderedProviders = + registry + .getOrderedProviders(orderString) + .filter { enabledStates[it.name] != false } + for (provider in orderedProviders) { + val providerResult = + try { + callProvider(provider, PER_PROVIDER_TIMEOUT_MS, videoId, cleanedTitle, cleanedArtist, duration, album) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "${provider.name} threw while browsing: ${e.message}") + null + } + val entries = providerResult?.getOrNull() + if (!entries.isNullOrEmpty()) { + val cleaned = LyricsUtils.filterCreditLines(normalizeEntries(entries.sorted())) + if (cleaned.isNotEmpty() && hasReasonableTimestamps(cleaned, duration)) { + onCandidate(LyricsCandidate(provider.name, cleaned, entriesAreSynced(cleaned))) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "${provider.name} threw while browsing: ${e.message}") - null - } - val entries = providerResult?.getOrNull() - if (!entries.isNullOrEmpty()) { - val cleaned = LyricsUtils.filterCreditLines(normalizeEntries(entries.sorted())) - if (cleaned.isNotEmpty() && hasReasonableTimestamps(cleaned, duration)) { - onCandidate(LyricsCandidate(provider.name, cleaned, entriesAreSynced(cleaned))) } } } - } - /** - * Makes a user-chosen or user-edited set of entries the lyrics of record for [videoId] by - * overwriting both the in-memory and disk caches, so it survives sheet reopens and restarts. - */ - suspend fun applyManualLyrics( - videoId: String, - entries: List, - ctx: Context? = null, - ) { - if (entries.isEmpty()) return - cache[videoId] = entries - try { - LyricsCacheManager.saveLyrics(ctx ?: context, videoId, entries) - } catch (e: Exception) { - Log.w(TAG, "Manual lyrics save failed: ${e.message}") + /** + * Makes a user-chosen or user-edited set of entries the lyrics of record for [videoId] by + * overwriting both the in-memory and disk caches, so it survives sheet reopens and restarts. + */ + suspend fun applyManualLyrics( + videoId: String, + entries: List, + ctx: Context? = null, + ) { + if (entries.isEmpty()) return + cache[videoId] = entries + try { + LyricsCacheManager.saveLyrics(ctx ?: context, videoId, entries) + } catch (e: Exception) { + Log.w(TAG, "Manual lyrics save failed: ${e.message}") + } } } -} data class LyricsCandidate( val providerName: String, diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsMatching.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsMatching.kt new file mode 100644 index 000000000..66f095591 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsMatching.kt @@ -0,0 +1,200 @@ +package io.github.aedev.flow.data.lyrics + +import kotlin.math.abs + +/** + * How a lyrics search names a song, and how a provider's candidates are checked against it. + * Credits and packaging ("feat. X", "Official Video") are dropped from the search. Version tags + * ("Live", "Remix", "Sped Up") name a different recording, so they stay, and a candidate has to + * carry the same ones: otherwise a live cut gets the studio version's timing. + * + * The version and scoring rules follow LastWave-Native (GPL-3.0). + */ +internal object LyricsMatching { + data class Song( + val title: String, + val artist: String, + val durationSeconds: Int, + ) + + /** + * Exact title and artist agreement. A fuzzy title passes only with a close duration too. A + * candidate with a different title, or a different artist when one is asked for, scores 0. + */ + const val MIN_SCORE = 5 + + private const val EXACT_TITLE = 3 + private const val FUZZY_TITLE = 1 + private const val ARTIST = 2 + private const val CLOSE_DURATION = 3 + private const val NEAR_DURATION = 1 + private const val CLOSE_DURATION_SECONDS = 3 + private const val NEAR_DURATION_SECONDS = 10 + + private val FEAT_BRACKET = Regex("""(?i)\s*[(\[]\s*(?:feat\.?|ft\.?|featuring|with)\s[^)\]]*[)\]]""") + private val FEAT_TRAILING = Regex("""(?i)\s+(?:feat\.?|ft\.?|featuring)\s+.*$""") + private val PACKAGING_BRACKET = + Regex( + """(?i)\s*[(\[]\s*(?:official\s*)?(?:music\s*)?(?:video|audio|visuali[sz]er|lyrics?\s*video|lyrics?|m/?v|hd|hq|4k|clip|full\s*song|official)\s*[)\]]""", + ) + private val PACKAGING_SUFFIX = + Regex("""(?i)\s*[-|]\s*(?:official\s*)?(?:music\s*)?(?:video|audio|visuali[sz]er|lyrics?\s*video|lyrics?)\s*$""") + private val BRACKETED = Regex("""[(\[](.*?)[)\]]""") + private val DASH_SUFFIX = Regex("""\s+[-–—]\s+([^-–—(\[]+)$""") + private val ARTIST_SPLIT = Regex("""(?i)\s*(?:,|&|/|\bx\b|\band\b|\bvs\.?|\bfeat\.?|\bft\.?|\bfeaturing\b|\bwith\b)\s*""") + private val PRIMARY_ARTIST_SPLIT = Regex("""(?i)\s+(feat\.?|ft\.?|featuring|&|,|vs\.?)\s+""") + private val TOPIC_SUFFIX = Regex("""(?i)\s*-\s*Topic""") + private val APOSTROPHES = Regex("""['’`]""") + private val NON_WORD = Regex("""[^\p{L}\p{N}]+""") + + private val VERSION_KEYWORDS = + listOf( + "live" to "live", + "concert" to "live", + "session" to "live", + "unplugged" to "unplugged", + "acoustic" to "acoustic", + "remix" to "remix", + "cover" to "cover", + "karaoke" to "karaoke", + "instrumental" to "instrumental", + "slowed" to "slowed", + "sped up" to "sped", + "speed up" to "sped", + "spedup" to "sped", + "nightcore" to "sped", + "demo" to "demo", + "8d" to "8d", + ).map { (keyword, tag) -> Regex("""(?i)\b${Regex.escape(keyword)}\b""") to tag } + + fun cleanTitle(raw: String): String = + raw + .replace(FEAT_BRACKET, "") + .replace(FEAT_TRAILING, "") + .replace(PACKAGING_BRACKET, "") + .replace(PACKAGING_SUFFIX, "") + .replace(Regex("""\s+"""), " ") + .trim() + .ifBlank { raw.trim() } + + /** The first credited artist, which is the one every catalogue files the song under. */ + fun cleanArtist(raw: String): String = + raw + .replace(TOPIC_SUFFIX, "") + .split(PRIMARY_ARTIST_SPLIT)[0] + .trim() + + fun versionTags(title: String): Set { + val segments = BRACKETED.findAll(title).map { it.groupValues[1] } + listOfNotNull(DASH_SUFFIX.find(title)?.groupValues?.get(1)) + return segments + .flatMap { segment -> VERSION_KEYWORDS.filter { (pattern, _) -> pattern.containsMatchIn(segment) }.map { it.second } } + .toSet() + } + + fun sameVersion( + requestTitle: String, + candidateTitle: String, + ): Boolean = versionTags(requestTitle) == versionTags(candidateTitle) + + fun score( + request: Song, + candidate: Song, + ): Int { + var score = titleScore(candidate.title, request.title) + if (score == 0) return 0 + if (artistMatches(candidate.artist, request.artist)) { + score += ARTIST + } else if (request.artist.isNotBlank()) { + return 0 + } + if (request.durationSeconds > 0 && candidate.durationSeconds > 0) { + score += + when (abs(request.durationSeconds - candidate.durationSeconds)) { + in 0..CLOSE_DURATION_SECONDS -> CLOSE_DURATION + in 0..NEAR_DURATION_SECONDS -> NEAR_DURATION + else -> 0 + } + } + return score + } + + /** + * The candidate naming the same recording: same version tags, a score at or above + * [MIN_SCORE], then the highest score, then [preferred] ones, then the closest duration. + */ + fun best( + candidates: List, + request: Song, + describe: (T) -> Song, + preferred: (T) -> Boolean = { false }, + ): T? = + candidates + .map { it to describe(it) } + .filter { (_, song) -> sameVersion(request.title, song.title) } + .map { (candidate, song) -> Triple(candidate, song, score(request, song)) } + .filter { (_, _, score) -> score >= MIN_SCORE } + .sortedWith( + compareByDescending> { it.third } + .thenByDescending { preferred(it.first) } + .thenBy { durationGap(request, it.second) }, + ).firstOrNull() + ?.first + + private fun durationGap( + request: Song, + candidate: Song, + ): Int = + if (request.durationSeconds > 0 && candidate.durationSeconds > 0) { + abs(request.durationSeconds - candidate.durationSeconds) + } else { + Int.MAX_VALUE + } + + private fun titleScore( + candidate: String, + request: String, + ): Int { + val a = normalize(baseTitle(candidate)) + val b = normalize(baseTitle(request)) + return when { + a.isEmpty() || b.isEmpty() -> 0 + a == b -> EXACT_TITLE + similar(a, b, minRatio = 0.7) -> FUZZY_TITLE + else -> 0 + } + } + + private fun artistMatches( + candidate: String, + request: String, + ): Boolean { + val wanted = normalize(cleanArtist(request)) + if (wanted.isEmpty() || candidate.isBlank()) return false + val credited = listOf(candidate) + candidate.split(ARTIST_SPLIT) + return credited.map(::normalize).filter { it.isNotEmpty() }.any { it == wanted || similar(it, wanted, minRatio = 0.6) } + } + + /** The title without its brackets and trailing "- …" part, once versions are known to agree. */ + private fun baseTitle(title: String): String = cleanTitle(title).replace(BRACKETED, " ").replace(DASH_SUFFIX, "") + + private fun normalize(text: String): String = + text + .lowercase() + .replace(APOSTROPHES, "") + .replace(NON_WORD, " ") + .trim() + + private fun similar( + a: String, + b: String, + minRatio: Double, + ): Boolean { + if (a.contains(b) || b.contains(a)) { + val ratio = minOf(a.length, b.length).toDouble() / maxOf(a.length, b.length) + if (ratio >= minRatio) return true + } + val aTokens = a.split(' ').toSet() + val bTokens = b.split(' ').toSet() + return aTokens.intersect(bTokens).size.toDouble() / aTokens.union(bTokens).size >= 0.5 + } +} diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsPlusProvider.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsPlusProvider.kt index 87761e3fe..27498ad32 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsPlusProvider.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsPlusProvider.kt @@ -2,44 +2,28 @@ package io.github.aedev.flow.data.lyrics import android.util.Log import com.google.gson.Gson -import io.github.aedev.flow.network.AppProxyManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import java.net.URLEncoder -import java.util.concurrent.TimeUnit /** - * LyricsPlus/Binimum provider, adapted from Metrolist. + * LyricsPlus provider, adapted from Metrolist. * * This source often has fuller word-level lyrics than LRCLib and can fill gaps * that BetterLyrics/SimpMusic miss. */ -class LyricsPlusProvider : LyricsProvider { +class LyricsPlusProvider( + private val client: OkHttpClient, +) : LyricsProvider { override val name = "LyricsPlus" - private val client: OkHttpClient - get() = AppProxyManager.applyTo(OkHttpClient.Builder()) - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .build() - private val gson = Gson() - private val baseUrls = listOf( - "https://lyricsplus.binimum.org", - "https://lyricsplus.atomix.one", - "https://lyricsplus.prjktla.my.id", - "https://lyricsplus-seven.vercel.app" - ) - - @Volatile - private var lastWorkingServer: String? = null - private data class LyricsPlusResponse( val type: String? = null, - val lyrics: List? = null + val lyrics: List? = null, ) private data class LyricLine( @@ -47,27 +31,18 @@ class LyricsPlusProvider : LyricsProvider { val duration: Long = 0, val text: String = "", val syllabus: List? = null, - val element: LineElement? = null + val element: LineElement? = null, ) private data class LineElement( - val singer: String? = null + val singer: String? = null, ) private data class LyricWord( val time: Long = 0, val duration: Long = 0, val text: String = "", - val isBackground: Boolean = false - ) - - private data class BinimumLyricsApiResponse( - val results: List = emptyList() - ) - - private data class BinimumLyricsResult( - val timing_type: String? = null, - val lyricsUrl: String? = null + val isBackground: Boolean = false, ) override suspend fun getLyrics( @@ -75,81 +50,37 @@ class LyricsPlusProvider : LyricsProvider { title: String, artist: String, duration: Int, - album: String? - ): Result> = withContext(Dispatchers.IO) { - runCatching { - fetchBinimumLyrics(title, artist, duration, album) - ?.takeIf { entries -> entries.any { !it.words.isNullOrEmpty() } } - ?: fetchLyricsPlus(title, artist, duration, album) - ?: throw IllegalStateException("LyricsPlus lyrics unavailable") - } - } - - private fun prioritizedServers(): List { - val last = lastWorkingServer - return if (last != null && last in baseUrls) { - listOf(last) + baseUrls.filter { it != last } - } else { - baseUrls + album: String?, + ): Result> = + withContext(Dispatchers.IO) { + runCatching { + fetchLyricsPlus(title, artist, duration, album) + ?: throw IllegalStateException("LyricsPlus lyrics unavailable") + } } - } private fun fetchLyricsPlus( title: String, artist: String, duration: Int, - album: String? + album: String?, ): List? { if (title.isBlank() || artist.isBlank()) return null - for (baseUrl in prioritizedServers()) { - try { - val url = buildString { - append(baseUrl.trimEnd('/')) + return try { + val url = + buildString { + append(LYRICS_PLUS_BASE_URL) append("/v2/lyrics/get") append("?title=${title.urlEncoded()}") append("&artist=${artist.urlEncoded()}") if (duration > 0) append("&duration=$duration") if (!album.isNullOrBlank()) append("&album=${album.urlEncoded()}") } - val body = executeGet(url) ?: continue - val response = gson.fromJson(body, LyricsPlusResponse::class.java) - val entries = convertLyricsPlus(response) - if (!entries.isNullOrEmpty()) { - lastWorkingServer = baseUrl - return entries - } - } catch (e: Exception) { - Log.d(TAG, "LyricsPlus failed from $baseUrl: ${e.message}") - } - } - - return null - } - - private fun fetchBinimumLyrics( - title: String, - artist: String, - duration: Int, - album: String? - ): List? { - if (title.isBlank() || artist.isBlank()) return null - - return try { - val url = buildString { - append(BINIMUM_API_BASE_URL) - append("?track=${title.urlEncoded()}") - append("&artist=${artist.urlEncoded()}") - if (duration > 0) append("&duration=$duration") - if (!album.isNullOrBlank()) append("&album=${album.urlEncoded()}") - } val body = executeGet(url) ?: return null - val response = gson.fromJson(body, BinimumLyricsApiResponse::class.java) - val selected = response.results.firstOrNull { !it.lyricsUrl.isNullOrBlank() } ?: return null - val ttml = executeGet(selected.lyricsUrl!!) ?: return null - TTMLParser.parseTTMLToLyricsEntries(ttml).takeIf { it.isNotEmpty() } + convertLyricsPlus(gson.fromJson(body, LyricsPlusResponse::class.java)) } catch (e: Exception) { - Log.d(TAG, "Binimum lyrics failed: ${e.message}") + Log.d(TAG, "LyricsPlus failed: ${e.message}") null } } @@ -164,32 +95,35 @@ class LyricsPlusProvider : LyricsProvider { val mainWords = allWords.filter { !it.isBackground } val bgWords = allWords.filter { it.isBackground } - val mainText = when { - isWordSync && mainWords.isNotEmpty() -> buildText(mainWords) - mainWords.isEmpty() && bgWords.isNotEmpty() -> "" - else -> line.text.trim() - } + val mainText = + when { + isWordSync && mainWords.isNotEmpty() -> buildText(mainWords) + mainWords.isEmpty() && bgWords.isNotEmpty() -> "" + else -> line.text.trim() + } if (mainText.isNotBlank()) { - result += LyricsEntry( - time = line.time, - text = mainText, - words = if (isWordSync) mainWords.toWordTimestamps() else null, - agent = line.element?.singer, - isBackground = false - ) + result += + LyricsEntry( + time = line.time, + text = mainText, + words = if (isWordSync) mainWords.toWordTimestamps() else null, + agent = line.element?.singer, + isBackground = false, + ) } if (bgWords.isNotEmpty()) { val bgText = buildText(bgWords) if (bgText.isNotBlank()) { - result += LyricsEntry( - time = bgWords.minOf { it.time }, - text = bgText, - words = if (isWordSync) bgWords.toWordTimestamps() else null, - agent = "bg", - isBackground = true - ) + result += + LyricsEntry( + time = bgWords.minOf { it.time }, + text = bgText, + words = if (isWordSync) bgWords.toWordTimestamps() else null, + agent = "bg", + isBackground = true, + ) } } } @@ -197,28 +131,26 @@ class LyricsPlusProvider : LyricsProvider { return result.sorted().takeIf { it.isNotEmpty() } } - private fun List.toWordTimestamps(): List? { - return filter { it.text.isNotBlank() } + private fun List.toWordTimestamps(): List? = + filter { it.text.isNotBlank() } .map { WordTimestamp( text = it.text.trim(), startTime = it.time, - endTime = it.time + it.duration.coerceAtLeast(1) + endTime = it.time + it.duration.coerceAtLeast(1), ) - } - .takeIf { it.isNotEmpty() } - } + }.takeIf { it.isNotEmpty() } - private fun buildText(words: List): String { - return words.joinToString("") { it.text }.trim() - } + private fun buildText(words: List): String = words.joinToString("") { it.text }.trim() private fun executeGet(url: String): String? { - val request = Request.Builder() - .url(url) - .header("User-Agent", USER_AGENT) - .header("Accept", "application/json,text/plain,*/*") - .build() + val request = + Request + .Builder() + .url(url) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json,text/plain,*/*") + .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) return null @@ -231,6 +163,9 @@ class LyricsPlusProvider : LyricsProvider { companion object { private const val TAG = "LyricsPlusProvider" private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - private const val BINIMUM_API_BASE_URL = "https://lyrics-api.binimum.org/" + + // The other public mirrors (atomix.one, prjktla.my.id, lyricsplus-seven.vercel.app) were + // all down on 2026-10-19: no connection, 502 and DEPLOYMENT_DISABLED. + private const val LYRICS_PLUS_BASE_URL = "https://lyricsplus.binimum.org" } } diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsProviderOrder.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsProviderOrder.kt new file mode 100644 index 000000000..7edf2dd2a --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsProviderOrder.kt @@ -0,0 +1,41 @@ +package io.github.aedev.flow.data.lyrics + +/** + * The order lyrics providers are tried in, as saved in settings. A provider the saved order has + * never seen joins next to the provider it follows by default, not at the end, so an update that + * adds one reaches people who reordered the list once. + */ +internal object LyricsProviderOrder { + val DEFAULT: List = + listOf( + "BetterLyrics", + LrcRedLyricsProvider.NAME, + "LyricsPlus", + "SimpMusic", + "KuGou", + "Paxsenix", + "LrcLib", + UnisonLyricsProvider.NAME, + "YouTubeSubtitle", + "YouTube", + ) + + fun resolve(saved: String): List { + if (saved.isBlank()) return DEFAULT + val order = + saved + .split(",") + .map { it.trim() } + .filter { it in DEFAULT } + .distinct() + .toMutableList() + DEFAULT.forEachIndexed { index, name -> + if (name in order) return@forEachIndexed + val predecessor = DEFAULT.subList(0, index).lastOrNull { it in order } + order.add(predecessor?.let { order.indexOf(it) + 1 } ?: 0, name) + } + return order + } + + fun serialize(names: List): String = names.filter { it in DEFAULT }.distinct().joinToString(",") +} diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsProviderRegistry.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsProviderRegistry.kt index e896bf095..065c17ddc 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsProviderRegistry.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsProviderRegistry.kt @@ -1,51 +1,28 @@ package io.github.aedev.flow.data.lyrics +import okhttp3.OkHttpClient + class LyricsProviderRegistry( - private val providerMap: Map + private val providerMap: Map, ) { - val providerNames: List = providerMap.keys.toList() - - fun getProviderByName(name: String): LyricsProvider? = providerMap[name] - - fun deserializeProviderOrder(orderString: String): List { - if (orderString.isBlank()) return getDefaultProviderOrder() - return orderString.split(",").map { it.trim() }.filter { it in providerNames } - } - - fun serializeProviderOrder(providers: List): String { - return providers.filter { it in providerNames }.joinToString(",") - } - - fun getDefaultProviderOrder(): List = listOf( - "BetterLyrics", - "LyricsPlus", - "SimpMusic", - "KuGou", - "Paxsenix", - "LrcLib", - "YouTubeSubtitle", - "YouTube", - ).filter { it in providerNames } - - fun getOrderedProviders(orderString: String): List { - val order = deserializeProviderOrder(orderString) - val ordered = order.mapNotNull { getProviderByName(it) } - val missing = providerMap.values.filter { p -> ordered.none { it.name == p.name } } - return ordered + missing - } + fun getOrderedProviders(orderString: String): List = + LyricsProviderOrder.resolve(orderString).mapNotNull { providerMap[it] } companion object { - fun default(): LyricsProviderRegistry { - val providers = listOf( - BetterLyricsProvider(), - LyricsPlusProvider(), - SimpMusicLyricsProvider(), - KuGouLyricsProvider(), - PaxsenixLyricsProvider(), - LrcLibLyricsProvider(), - YouTubeSubtitleLyricsProvider(), - YouTubeLyricsProvider(), - ) + fun create(client: OkHttpClient): LyricsProviderRegistry { + val providers = + listOf( + BetterLyricsProvider(client), + LrcRedLyricsProvider(client), + LyricsPlusProvider(client), + SimpMusicLyricsProvider(client), + KuGouLyricsProvider(client), + PaxsenixLyricsProvider(client), + LrcLibLyricsProvider(client), + UnisonLyricsProvider(client), + YouTubeSubtitleLyricsProvider(), + YouTubeLyricsProvider(), + ) return LyricsProviderRegistry(providers.associateBy { it.name }) } } diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsUtils.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsUtils.kt index 159ddbf73..7b0a10509 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsUtils.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/LyricsUtils.kt @@ -5,10 +5,9 @@ package io.github.aedev.flow.data.lyrics * Supports standard LRC, rich sync (word-by-word), and plain text. */ object LyricsUtils { - private val LINE_REGEX = "((\\[\\d{1,2}:\\d{2}[.:]\\d{2,3}\\] ?)+)(.*)".toRegex() private val TIME_REGEX = "\\[(\\d{1,2}):(\\d{2})[.:](\\d{2,3})\\]".toRegex() - + private val RICH_SYNC_LINE_REGEX = "\\[(\\d{1,2}):(\\d{2})\\.(\\d{2,3})\\](.*)".toRegex() private val RICH_SYNC_WORD_REGEX = "<(\\d{1,2}):(\\d{2})\\.(\\d{2,3})>\\s*([^<]+)".toRegex() private val PAXSENIX_AGENT_LINE_REGEX = "\\[(\\d{1,2}):(\\d{2})\\.(\\d{2,3})\\](v\\d+):\\s*(.*)".toRegex() @@ -16,15 +15,16 @@ object LyricsUtils { private val AGENT_REGEX = "\\{agent:([^}]+)\\}".toRegex() private val BACKGROUND_REGEX = "^\\{bg\\}".toRegex() private val HTML_NUMERIC_ENTITY_REGEX = "&#(x?[0-9A-Fa-f]+);".toRegex() - private val HTML_NAMED_ENTITIES = mapOf( - "&" to "&", - "'" to "'", - "'" to "'", - """ to "\"", - "<" to "<", - ">" to ">", - " " to " " - ) + private val HTML_NAMED_ENTITIES = + mapOf( + "&" to "&", + "'" to "'", + "'" to "'", + """ to "\"", + "<" to "<", + ">" to ">", + " " to " ", + ) fun decodeHtmlEntities(text: String): String { var decoded = text @@ -32,17 +32,20 @@ object LyricsUtils { HTML_NAMED_ENTITIES.forEach { (entity, replacement) -> decoded = decoded.replace(entity, replacement) } - decoded = HTML_NUMERIC_ENTITY_REGEX.replace(decoded) { match -> - val raw = match.groupValues[1] - val codePoint = if (raw.startsWith("x", ignoreCase = true)) { - raw.drop(1).toIntOrNull(16) - } else { - raw.toIntOrNull() + decoded = + HTML_NUMERIC_ENTITY_REGEX.replace(decoded) { match -> + val raw = match.groupValues[1] + val codePoint = + if (raw.startsWith("x", ignoreCase = true)) { + raw.drop(1).toIntOrNull(16) + } else { + raw.toIntOrNull() + } + codePoint + ?.takeIf { Character.isValidCodePoint(it) } + ?.let { String(Character.toChars(it)) } + ?: match.value } - codePoint?.takeIf { Character.isValidCodePoint(it) } - ?.let { String(Character.toChars(it)) } - ?: match.value - } } return decoded } @@ -52,23 +55,27 @@ object LyricsUtils { * Auto-detects rich sync vs standard LRC format. */ fun parseLyrics(lyrics: String): List { - val unescaped = lyrics - .trim() - .removePrefix("\"") - .removeSuffix("\"") - .replace("\\\\", "\\") - .replace("\\n", "\n") - .replace("\\r", "\r") - .replace("\\t", "\t") - - val lines = unescaped.lines() - .filter { it.isNotBlank() && !it.trim().startsWith("[offset:") } - - val isRichSync = lines.any { line -> - (RICH_SYNC_LINE_REGEX.matches(line.trim()) && RICH_SYNC_WORD_REGEX.containsMatchIn(line)) || - PAXSENIX_AGENT_LINE_REGEX.containsMatchIn(line.trim()) || - PAXSENIX_BG_LINE_REGEX.containsMatchIn(line.trim()) - } + val unescaped = + lyrics + .trim() + .removePrefix("\"") + .removeSuffix("\"") + .replace("\\\\", "\\") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") + + val lines = + unescaped + .lines() + .filter { it.isNotBlank() && !it.trim().startsWith("[offset:") } + + val isRichSync = + lines.any { line -> + (RICH_SYNC_LINE_REGEX.matches(line.trim()) && RICH_SYNC_WORD_REGEX.containsMatchIn(line)) || + PAXSENIX_AGENT_LINE_REGEX.containsMatchIn(line.trim()) || + PAXSENIX_BG_LINE_REGEX.containsMatchIn(line.trim()) + } return if (isRichSync) { parseRichSyncLyrics(lines) @@ -89,8 +96,9 @@ object LyricsUtils { PAXSENIX_BG_LINE_REGEX.find(trimmedLine)?.let { bgMatch -> val content = bgMatch.groupValues[1] - val wordTimings = parseRichSyncWords(content, index, lines) - ?: parseFollowingWordBlock(index, lines) + val wordTimings = + parseRichSyncWords(content, index, lines) + ?: parseFollowingWordBlock(index, lines) val plainText = stripRichSyncTags(content) val lineTimeMs = wordTimings?.firstOrNull()?.startTime ?: 0L if (plainText.isNotBlank()) { @@ -100,8 +108,8 @@ object LyricsUtils { text = plainText, words = wordTimings, agent = lastNonBgAgent ?: "bg", - isBackground = true - ) + isBackground = true, + ), ) } return@forEachIndexed @@ -111,8 +119,9 @@ object LyricsUtils { val lineTimeMs = parseTimestamp(agentMatch.groupValues) val agent = agentMatch.groupValues[4] val content = agentMatch.groupValues[5] - val wordTimings = parseRichSyncWords(content, index, lines) - ?: parseFollowingWordBlock(index, lines) + val wordTimings = + parseRichSyncWords(content, index, lines) + ?: parseFollowingWordBlock(index, lines) val plainText = stripRichSyncTags(content) if (agent.isNotBlank()) lastNonBgAgent = agent if (plainText.isNotBlank()) { @@ -122,8 +131,8 @@ object LyricsUtils { text = plainText, words = wordTimings, agent = agent, - isBackground = false - ) + isBackground = false, + ), ) } return@forEachIndexed @@ -151,8 +160,9 @@ object LyricsUtils { content = content.replaceFirst(BACKGROUND_REGEX, "") } - val wordTimings = parseRichSyncWords(content, index, lines) - ?: parseFollowingWordBlock(index, lines) + val wordTimings = + parseRichSyncWords(content, index, lines) + ?: parseFollowingWordBlock(index, lines) val plainText = stripRichSyncTags(content) @@ -164,8 +174,8 @@ object LyricsUtils { text = plainText, words = wordTimings, agent = if (isBackground) lastNonBgAgent ?: "bg" else agent, - isBackground = isBackground - ) + isBackground = isBackground, + ), ) } } @@ -190,11 +200,13 @@ object LyricsUtils { ) { val candidate = entries[nextIndex] if (candidate.words.isNullOrEmpty() && candidate.text.isNotBlank()) { - base = base.copy( - translation = listOfNotNull(base.translation, candidate.text) - .joinToString("\n") - .ifBlank { null } - ) + base = + base.copy( + translation = + listOfNotNull(base.translation, candidate.text) + .joinToString("\n") + .ifBlank { null }, + ) nextIndex++ } else { break @@ -212,10 +224,10 @@ object LyricsUtils { private fun parseRichSyncWords( content: String, currentIndex: Int, - allLines: List + allLines: List, ): List? { val matches = RICH_SYNC_WORD_REGEX.findAll(content).toList() - + if (matches.isEmpty()) return null val wordTimings = mutableListOf() @@ -223,16 +235,18 @@ object LyricsUtils { matches.forEachIndexed { index, match -> val startTime = parseTimestamp(match.groupValues) - val nextStart = if (index < matches.lastIndex) { - parseTimestamp(matches[index + 1].groupValues) - } else { - trailingEndTime - ?: getNextLineStartTimeMs(currentIndex, allLines)?.takeIf { it > startTime } - ?: (startTime + 500) - } + val nextStart = + if (index < matches.lastIndex) { + parseTimestamp(matches[index + 1].groupValues) + } else { + trailingEndTime + ?: getNextLineStartTimeMs(currentIndex, allLines)?.takeIf { it > startTime } + ?: (startTime + 500) + } - val rawText = match.groupValues[4] - .replace(Regex("\\[(\\d{1,2}):(\\d{2})\\.(\\d{2,3})\\]\\s*$"), "") + val rawText = + match.groupValues[4] + .replace(Regex("\\[(\\d{1,2}):(\\d{2})\\.(\\d{2,3})\\]\\s*$"), "") val words = rawText.trim().split(Regex("\\s+")).filter { it.isNotBlank() } words.forEachIndexed { wordIndex, word -> val wordStart = startTime + ((nextStart - startTime) * wordIndex / words.size.coerceAtLeast(1)) @@ -244,23 +258,29 @@ object LyricsUtils { return wordTimings.takeIf { it.isNotEmpty() } } - private fun stripRichSyncTags(content: String): String { - return content + private fun stripRichSyncTags(content: String): String = + content .replace(Regex("<\\d{1,2}:\\d{2}\\.\\d{2,3}>\\s*"), " ") .replace(Regex("\\[\\d{1,2}:\\d{2}\\.\\d{2,3}\\]\\s*$"), " ") .replace(Regex("\\s+"), " ") .replace(Regex("\\s+([,.;:!?%])"), "$1") .trim() - } - private fun parseTrailingRichSyncEndTime(content: String, fromIndex: Int): Long? { + private fun parseTrailingRichSyncEndTime( + content: String, + fromIndex: Int, + ): Long? { val trailing = content.substring(fromIndex).trim() - val match = Regex("^(?:<|\\[)(\\d{1,2}):(\\d{2})\\.(\\d{2,3})(?:>|\\])$").find(trailing) - ?: return null + val match = + Regex("^(?:<|\\[)(\\d{1,2}):(\\d{2})\\.(\\d{2,3})(?:>|\\])$").find(trailing) + ?: return null return parseTimestamp(match.groupValues) } - private fun parseFollowingWordBlock(currentIndex: Int, allLines: List): List? { + private fun parseFollowingWordBlock( + currentIndex: Int, + allLines: List, + ): List? { val nextLine = allLines.getOrNull(currentIndex + 1)?.trim() ?: return null return if (nextLine.startsWith("<") && nextLine.endsWith(">")) { parseMetrolistWordTimestamps(nextLine.removeSurrounding("<", ">")) @@ -277,7 +297,10 @@ object LyricsUtils { return min * 60000 + sec * 1000 + millis } - private fun getNextLineStartTimeMs(currentIndex: Int, allLines: List): Long? { + private fun getNextLineStartTimeMs( + currentIndex: Int, + allLines: List, + ): Long? { if (currentIndex + 1 >= allLines.size) return null val nextLine = allLines[currentIndex + 1].trim() val match = RICH_SYNC_LINE_REGEX.matchEntire(nextLine) ?: return null @@ -288,7 +311,6 @@ object LyricsUtils { return min * 60000 + sec * 1000 + millisPart } - /** * Parse standard LRC format: [MM:SS.mm] text * Also supports Metrolist's word-data format where word timings are on the next line: @@ -301,7 +323,7 @@ object LyricsUtils { var i = 0 while (i < lines.size) { val line = lines[i].trim() - + if (line.startsWith("<") && line.endsWith(">")) { i++ continue @@ -311,12 +333,17 @@ object LyricsUtils { if (timeMatchResults.isNotEmpty()) { val content = line.replace(TIME_REGEX, "").trim() if (content.isNotEmpty()) { - val wordTimestamps = if (i + 1 < lines.size) { - val nextLine = lines[i + 1].trim() - if (nextLine.startsWith("<") && nextLine.endsWith(">")) { - parseMetrolistWordTimestamps(nextLine.removeSurrounding("<", ">")) - } else null - } else null + val wordTimestamps = + if (i + 1 < lines.size) { + val nextLine = lines[i + 1].trim() + if (nextLine.startsWith("<") && nextLine.endsWith(">")) { + parseMetrolistWordTimestamps(nextLine.removeSurrounding("<", ">")) + } else { + null + } + } else { + null + } timeMatchResults.forEach { match -> val min = match.groupValues[1].toLong() @@ -348,9 +375,11 @@ object LyricsUtils { WordTimestamp( text = text, startTime = (parts[parts.size - 2].toDouble() * 1000).toLong(), - endTime = (parts[parts.size - 1].toDouble() * 1000).toLong() + endTime = (parts[parts.size - 1].toDouble() * 1000).toLong(), ) - } else null + } else { + null + } } } catch (e: Exception) { null @@ -360,7 +389,10 @@ object LyricsUtils { /** * Find the index of the current lyrics line based on playback position. */ - fun findCurrentLineIndex(lines: List, position: Long): Int { + fun findCurrentLineIndex( + lines: List, + position: Long, + ): Int { for (index in lines.indices) { if (lines[index].time >= position + 300L) { return index - 1 @@ -369,29 +401,16 @@ object LyricsUtils { return lines.lastIndex } - fun filterCreditLines(entries: List): List { - return entries.filter { entry -> + fun filterCreditLines(entries: List): List = + entries.filter { entry -> val lower = entry.text.trim().lowercase() - !(lower.startsWith("synced by") || - lower.startsWith("lyrics by") || - lower.startsWith("music by") || - lower.startsWith("arranged by") || - lower.startsWith("written by") || - lower.startsWith("composed by")) + !( + lower.startsWith("synced by") || + lower.startsWith("lyrics by") || + lower.startsWith("music by") || + lower.startsWith("arranged by") || + lower.startsWith("written by") || + lower.startsWith("composed by") + ) } - } - - fun cleanTitle(title: String): String { - return title - .replace(Regex("\\s*[(\\[].*?[)\\]]"), "") - .replace(Regex("(?i)\\b(official video|official audio|lyrics|lyric video|hq|hd|audio|video|clip)\\b"), "") - .trim() - } - - fun cleanArtist(artist: String): String { - return artist - .replace(Regex("(?i)\\s*-\\s*Topic"), "") - .split(Regex("(?i)\\s+(feat\\.?|ft\\.?|featuring|&|,|vs\\.?)\\s+"))[0] - .trim() - } } diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/PaxsenixLyricsProvider.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/PaxsenixLyricsProvider.kt index dd958f2f6..ab55d751b 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/PaxsenixLyricsProvider.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/PaxsenixLyricsProvider.kt @@ -1,28 +1,32 @@ -//================================================================================================== -//This implementation was based on metrolist's (https://github.com/MetrolistGroup/Metrolist) -//================================================================================================== +// ================================================================================================== +// This implementation was based on metrolist's (https://github.com/MetrolistGroup/Metrolist) +// ================================================================================================== package io.github.aedev.flow.data.lyrics import io.github.aedev.flow.data.lyrics.paxsenix.Paxsenix import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient -class PaxsenixLyricsProvider : LyricsProvider { +class PaxsenixLyricsProvider( + client: OkHttpClient, +) : LyricsProvider { override val name = "Paxsenix" + private val paxsenix = Paxsenix(client) + override suspend fun getLyrics( id: String, title: String, artist: String, duration: Int, - album: String? - ): Result> = withContext(Dispatchers.IO) { - runCatching { - val context = io.github.aedev.flow.FlowApplication.appContext - Paxsenix.init(context) - val lrc = Paxsenix.getLyrics(title, artist, duration, album).getOrThrow() - LyricsUtils.parseLyrics(lrc) + album: String?, + ): Result> = + withContext(Dispatchers.IO) { + runCatching { + val lrc = paxsenix.getLyrics(title, artist, duration, album).getOrThrow() + LyricsUtils.parseLyrics(lrc) + } } - } } diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/SimpMusicLyrics.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/SimpMusicLyrics.kt index 87ed70468..f6f4dcb00 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/SimpMusicLyrics.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/SimpMusicLyrics.kt @@ -8,12 +8,10 @@ import android.util.Log import com.google.gson.Gson import com.google.gson.JsonParser import com.google.gson.reflect.TypeToken -import io.github.aedev.flow.network.AppProxyManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import java.util.concurrent.TimeUnit import kotlin.math.abs /** @@ -24,18 +22,11 @@ import kotlin.math.abs * API endpoint: https://api-lyrics.simpmusic.org/v1/{videoId} * Response: {"type":"success","data":[{...lyrics data...}]} */ -object SimpMusicLyrics { - private const val TAG = "SimpMusicLyrics" - - private val client: OkHttpClient - get() = AppProxyManager.applyTo(OkHttpClient.Builder()) - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .build() +class SimpMusicLyrics( + private val client: OkHttpClient, +) { private val gson = Gson() - private const val BASE_URL = "https://api-lyrics.simpmusic.org/v1/" - /** * Rich sync JSON item structure from SimpMusic API. */ @@ -43,71 +34,87 @@ object SimpMusicLyrics { val ts: Double = 0.0, val te: Double = 0.0, val l: List? = null, - val x: String? = null + val x: String? = null, ) private data class RichSyncWord( val c: String = "", - val o: Double = 0.0 + val o: Double = 0.0, ) /** * Fetch lyrics by video ID and return structured entries. */ - suspend fun getLyrics(videoId: String, duration: Int? = null): Result> = withContext(Dispatchers.IO) { - try { - val url = BASE_URL + videoId - Log.d(TAG, "Fetching: $url") - - val request = Request.Builder() - .url(url) - .header("User-Agent", "SimpMusicLyrics/1.0") - .header("Accept", "application/json") - .build() - - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - return@withContext Result.failure( - Exception("SimpMusic API returned ${response.code}") - ) - } + suspend fun getLyrics( + videoId: String, + duration: Int? = null, + ): Result> = + withContext(Dispatchers.IO) { + try { + val url = BASE_URL + videoId + Log.d(TAG, "Fetching: $url") + + val request = + Request + .Builder() + .url(url) + .header("User-Agent", "SimpMusicLyrics/1.0") + .header("Accept", "application/json") + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + return@withContext Result.failure( + Exception("SimpMusic API returned ${response.code}"), + ) + } - val body = response.body?.string() - if (body.isNullOrBlank()) { - return@withContext Result.failure(Exception("Empty response from SimpMusic")) - } + val body = response.body?.string() + if (body.isNullOrBlank()) { + return@withContext Result.failure(Exception("Empty response from SimpMusic")) + } - val entries = parseSimpMusicResponse(body, duration) - if (entries != null && entries.isNotEmpty()) { - Log.d(TAG, "Got ${entries.size} lines, ${entries.count { it.words != null }} with word sync") - Result.success(entries) - } else { - Result.failure(Exception("No lyrics found in SimpMusic response")) + val entries = parseSimpMusicResponse(body, duration) + if (entries != null && entries.isNotEmpty()) { + Log.d(TAG, "Got ${entries.size} lines, ${entries.count { it.words != null }} with word sync") + Result.success(entries) + } else { + Result.failure(Exception("No lyrics found in SimpMusic response")) + } } + } catch (e: Exception) { + Log.e(TAG, "SimpMusic fetch failed", e) + Result.failure(e) } - } catch (e: Exception) { - Log.e(TAG, "SimpMusic fetch failed", e) - Result.failure(e) } - } - private fun parseSimpMusicResponse(json: String, duration: Int?): List? { + private fun parseSimpMusicResponse( + json: String, + duration: Int?, + ): List? { try { val root = JsonParser.parseString(json) - val lyricsArray = when { - root.isJsonArray -> root.asJsonArray - root.isJsonObject -> { - val obj = root.asJsonObject - val type = obj.get("type")?.asString - if (type != null && type != "success") { - Log.d(TAG, "API returned type=$type") + val lyricsArray = + when { + root.isJsonArray -> { + root.asJsonArray + } + + root.isJsonObject -> { + val obj = root.asJsonObject + val type = obj.get("type")?.asString + if (type != null && type != "success") { + Log.d(TAG, "API returned type=$type") + return null + } + obj.getAsJsonArray("data") ?: return null + } + + else -> { return null } - obj.getAsJsonArray("data") ?: return null } - else -> return null - } if (lyricsArray.size() == 0) return null @@ -118,9 +125,10 @@ object SimpMusicLyrics { val lyricObj = element.asJsonObject if (duration != null && duration > 0) { - val lyricDuration = lyricObj.get("durationSeconds")?.asInt - ?: lyricObj.get("duration")?.asInt - ?: 0 + val lyricDuration = + lyricObj.get("durationSeconds")?.asInt + ?: lyricObj.get("duration")?.asInt + ?: 0 val diff = abs(lyricDuration - duration).toDouble() if (diff <= 10 && diff < bestDurationDiff) { bestDurationDiff = diff @@ -152,8 +160,9 @@ object SimpMusicLyrics { } } - val plainStr = bestMatch?.get("plainLyrics")?.asString - ?: bestMatch?.get("plainLyric")?.asString + val plainStr = + bestMatch?.get("plainLyrics")?.asString + ?: bestMatch?.get("plainLyric")?.asString if (!plainStr.isNullOrBlank()) { Log.d(TAG, "Using plainLyrics") return listOf(LyricsEntry(time = 0L, text = plainStr)) @@ -169,7 +178,10 @@ object SimpMusicLyrics { * * Format: [{"ts": 1.23, "te": 5.67, "l": [{"c": "word", "o": 0.0}, ...], "x": "full line"}, ...] */ - private fun parseRichSyncJson(richSyncJson: String, duration: Int?): List? { + private fun parseRichSyncJson( + richSyncJson: String, + duration: Int?, + ): List? { val trimmedJson = richSyncJson.trim() if (trimmedJson.isBlank()) return null @@ -182,7 +194,8 @@ object SimpMusicLyrics { // no longer the expected object array — fall through to syncedLyrics silently. val parsed = JsonParser.parseString(trimmedJson) if (parsed.isJsonArray && parsed.asJsonArray.size() > 0 && - parsed.asJsonArray[0].isJsonPrimitive) { + parsed.asJsonArray[0].isJsonPrimitive + ) { Log.w(TAG, "richSyncLyrics is string-array (API format changed), skipping") return null } @@ -195,36 +208,41 @@ object SimpMusicLyrics { if (lineText.isBlank()) return@mapNotNull null val lineStartMs = normalizeTimeToMs(item.ts, duration) - val lineEndMs = normalizeTimeToMs(item.te, duration) - .takeIf { it > lineStartMs } - ?: (lineStartMs + 2_000L) - - val wordTimestamps = lineWords.mapNotNull { word -> - val trimmed = word.c.trim() - if (trimmed.isBlank()) { - null - } else { - WordTimestamp( - text = trimmed, - startTime = resolveWordStartMs(lineStartMs, lineEndMs, word.o), - endTime = lineEndMs - ) + val lineEndMs = + normalizeTimeToMs(item.te, duration) + .takeIf { it > lineStartMs } + ?: (lineStartMs + 2_000L) + + val wordTimestamps = + lineWords + .mapNotNull { word -> + val trimmed = word.c.trim() + if (trimmed.isBlank()) { + null + } else { + WordTimestamp( + text = trimmed, + startTime = resolveWordStartMs(lineStartMs, lineEndMs, word.o), + endTime = lineEndMs, + ) + } + }.sortedBy { it.startTime } + + val refinedWords = + wordTimestamps.mapIndexed { index, wt -> + val nextStart = + if (index < wordTimestamps.lastIndex) { + wordTimestamps[index + 1].startTime + } else { + lineEndMs + } + wt.copy(endTime = nextStart.coerceAtLeast(wt.startTime + 1)) } - }.sortedBy { it.startTime } - - val refinedWords = wordTimestamps.mapIndexed { index, wt -> - val nextStart = if (index < wordTimestamps.lastIndex) { - wordTimestamps[index + 1].startTime - } else { - lineEndMs - } - wt.copy(endTime = nextStart.coerceAtLeast(wt.startTime + 1)) - } LyricsEntry( time = lineStartMs, text = lineText.trim(), - words = refinedWords.takeIf { it.isNotEmpty() } + words = refinedWords.takeIf { it.isNotEmpty() }, ) } } catch (e: Exception) { @@ -247,18 +265,26 @@ object SimpMusicLyrics { return afterBracket == '{' || afterBracket == ']' } - private fun normalizeTimeToMs(value: Double, duration: Int?): Long { + private fun normalizeTimeToMs( + value: Double, + duration: Int?, + ): Long { val isProbablyMillis = value > 1_000 || (duration != null && duration > 0 && value > duration + 60) return if (isProbablyMillis) value.toLong() else (value * 1_000).toLong() } - private fun resolveWordStartMs(lineStartMs: Long, lineEndMs: Long, rawOffset: Double): Long { - val candidates = listOf( - lineStartMs + (rawOffset * 1_000).toLong(), - (rawOffset * 1_000).toLong(), - lineStartMs + rawOffset.toLong(), - rawOffset.toLong() - ).distinct() + private fun resolveWordStartMs( + lineStartMs: Long, + lineEndMs: Long, + rawOffset: Double, + ): Long { + val candidates = + listOf( + lineStartMs + (rawOffset * 1_000).toLong(), + (rawOffset * 1_000).toLong(), + lineStartMs + rawOffset.toLong(), + rawOffset.toLong(), + ).distinct() return candidates .filter { it in (lineStartMs - 1_000)..(lineEndMs + 1_000) } @@ -266,14 +292,18 @@ object SimpMusicLyrics { ?: (lineStartMs + (rawOffset * 1_000).toLong()).coerceAtLeast(lineStartMs) } - private fun buildLineText(words: List, fallback: String?): String { - val fromWords = words - .map { it.c.trim() } - .filter { it.isNotBlank() } - .joinToString(" ") - .replace(Regex("\\s+([,.;:!?%])"), "$1") - .replace(Regex("([([{])\\s+"), "$1") - .trim() + private fun buildLineText( + words: List, + fallback: String?, + ): String { + val fromWords = + words + .map { it.c.trim() } + .filter { it.isNotBlank() } + .joinToString(" ") + .replace(Regex("\\s+([,.;:!?%])"), "$1") + .replace(Regex("([([{])\\s+"), "$1") + .trim() val fallbackText = fallback?.trim().orEmpty() return when { @@ -283,4 +313,9 @@ object SimpMusicLyrics { else -> fallbackText } } + + private companion object { + const val TAG = "SimpMusicLyrics" + const val BASE_URL = "https://api-lyrics.simpmusic.org/v1/" + } } diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/SimpMusicLyricsProvider.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/SimpMusicLyricsProvider.kt index 4ca9541ef..37c3f40d4 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/SimpMusicLyricsProvider.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/SimpMusicLyricsProvider.kt @@ -4,20 +4,24 @@ package io.github.aedev.flow.data.lyrics +import okhttp3.OkHttpClient + /** * LyricsProvider implementation for the SimpMusic service. * Delegates to SimpMusicLyrics service which returns structured LyricsEntry with word timestamps. */ -class SimpMusicLyricsProvider : LyricsProvider { +class SimpMusicLyricsProvider( + client: OkHttpClient, +) : LyricsProvider { override val name = "SimpMusic" + private val simpMusic = SimpMusicLyrics(client) + override suspend fun getLyrics( id: String, title: String, artist: String, duration: Int, - album: String? - ): Result> { - return SimpMusicLyrics.getLyrics(id, duration) - } + album: String?, + ): Result> = simpMusic.getLyrics(id, duration) } diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/TTMLParser.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/TTMLParser.kt index 0ec519947..1647b2201 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/TTMLParser.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/TTMLParser.kt @@ -6,6 +6,7 @@ import org.w3c.dom.Node import org.xmlpull.v1.XmlPullParser import java.io.StringReader import javax.xml.parsers.DocumentBuilderFactory +import kotlin.math.roundToLong object TTMLParser { private const val TTML_PARAMETER_NS = "http://www.w3.org/ns/ttml#parameter" @@ -544,38 +545,33 @@ object TTMLParser { return result } + /** + * TTML offset times ("9.446s", "9446ms"), clock times ("0:01:02.345") and the shapes Apple + * Music TTML actually uses: bare seconds ("9.446") and minutes with seconds ("1:02.345"). + */ private fun parseTime(timeStr: String?): Long { - if (timeStr.isNullOrBlank()) return 0L - try { - if (timeStr.endsWith("ms")) { - return timeStr.replace("ms", "").toLongOrNull() ?: 0L - } - if (timeStr.endsWith("s")) { - val secStr = timeStr.replace("s", "") - val secDouble = secStr.toDoubleOrNull() ?: 0.0 - return (secDouble * 1000).toLong() - } - if (timeStr.contains(":")) { - val parts = timeStr.trim().split(":") - if (parts.size == 3) { - val h = parts[0].toLongOrNull() ?: 0L - val m = parts[1].toLongOrNull() ?: 0L - - val sParts = parts[2].split(".") - val s = sParts[0].toLongOrNull() ?: 0L - var ms = 0L - if (sParts.size > 1) { - var msStr = sParts[1] - if (msStr.length > 3) msStr = msStr.substring(0, 3) - while (msStr.length < 3) msStr += "0" - ms = msStr.toLongOrNull() ?: 0L - } - return (h * 3600000) + (m * 60000) + (s * 1000) + ms + val time = timeStr?.trim().orEmpty() + if (time.isEmpty()) return 0L + val seconds = + when { + time.endsWith("ms") -> { + time.removeSuffix("ms").toDoubleOrNull()?.div(1000) } - } - } catch (e: Exception) { - e.printStackTrace() - } - return 0L + + time.endsWith("s") -> { + time.removeSuffix("s").toDoubleOrNull() + } + + ':' in time -> { + val parts = time.split(':').map { it.toDoubleOrNull() ?: return 0L } + if (parts.size > 3) return 0L + parts.fold(0.0) { total, part -> total * 60 + part } + } + + else -> { + time.toDoubleOrNull() + } + } ?: return 0L + return (seconds * 1000).roundToLong() } } diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/UnisonLyricsProvider.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/UnisonLyricsProvider.kt new file mode 100644 index 000000000..a53cc0f19 --- /dev/null +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/UnisonLyricsProvider.kt @@ -0,0 +1,86 @@ +package io.github.aedev.flow.data.lyrics + +import io.github.aedev.flow.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Unison: lyrics people submit themselves, so its catalogue is small and uneven, but it sometimes + * has a song the licensed sources lack. Each entry says whether it is Apple-style TTML, LRC (word + * stamps included) or plain text, and that picks the parser. + */ +class UnisonLyricsProvider( + private val client: OkHttpClient, +) : LyricsProvider { + override val name = NAME + + @Serializable + private data class Response( + val success: Boolean? = null, + val data: Entry? = null, + ) + + @Serializable + private data class Entry( + val lyrics: String? = null, + val format: String? = null, + val syncType: String? = null, + ) + + override suspend fun getLyrics( + id: String, + title: String, + artist: String, + duration: Int, + album: String?, + ): Result> = + withContext(Dispatchers.IO) { + runCatching { + require(title.isNotBlank() && artist.isNotBlank()) { "Unison needs a title and an artist" } + val url = + LYRICS_URL + .toHttpUrl() + .newBuilder() + .addQueryParameter("song", title) + .addQueryParameter("artist", artist) + .apply { + if (!album.isNullOrBlank()) addQueryParameter("album", album) + if (duration > 0) addQueryParameter("duration", duration.toString()) + }.build() + val request = + Request + .Builder() + .url(url) + .header("User-Agent", "Flow/${BuildConfig.VERSION_NAME}") + .header("Accept", "application/json") + .build() + val body = + client.newCall(request).execute().use { response -> + if (response.isSuccessful) response.body.string() else null + } + body?.let(::parse)?.ifEmpty { null } ?: error("No Unison lyrics") + } + } + + internal companion object { + const val NAME = "Unison" + private const val LYRICS_URL = "https://unison.boidu.dev/lyrics" + private val json = Json { ignoreUnknownKeys = true } + + fun parse(body: String): List { + val response = json.decodeFromString(body) + val entry = response.data?.takeIf { response.success == true } ?: return emptyList() + val text = entry.lyrics?.takeIf { it.isNotBlank() } ?: return emptyList() + return when { + entry.format.equals("ttml", ignoreCase = true) -> TTMLParser.parseTTMLToLyricsEntries(text) + entry.syncType.equals("plain", ignoreCase = true) -> listOf(LyricsEntry(time = 0L, text = text.trim())) + else -> LyricsUtils.parseLyrics(text) + } + } + } +} diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/kugou/KuGou.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/kugou/KuGou.kt index 281c8cda2..0e227820f 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/kugou/KuGou.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/kugou/KuGou.kt @@ -1,6 +1,6 @@ -//================================================================================================== -//This implementation was based on metrolist's (https://github.com/MetrolistGroup/Metrolist) -//================================================================================================== +// ================================================================================================== +// This implementation was based on metrolist's (https://github.com/MetrolistGroup/Metrolist) +// ================================================================================================== package io.github.aedev.flow.data.lyrics.kugou @@ -10,45 +10,43 @@ import io.github.aedev.flow.data.lyrics.kugou.models.DownloadLyricsResponse import io.github.aedev.flow.data.lyrics.kugou.models.Keyword import io.github.aedev.flow.data.lyrics.kugou.models.SearchLyricsResponse import io.github.aedev.flow.data.lyrics.kugou.models.SearchSongResponse -import io.github.aedev.flow.network.AppProxyManager import okhttp3.OkHttpClient import okhttp3.Request import java.net.URLEncoder -import java.util.concurrent.TimeUnit import kotlin.math.abs import kotlin.math.min -object KuGou { - private const val TAG = "KuGou" - private const val PAGE_SIZE = 8 - private const val HEAD_CUT_LIMIT = 30 - private const val DURATION_TOLERANCE = 8 - - private val client: OkHttpClient - get() = AppProxyManager.applyTo(OkHttpClient.Builder()) - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .build() - +class KuGou( + private val client: OkHttpClient, +) { private val gson = Gson() - suspend fun getLyrics(title: String, artist: String, duration: Int, album: String? = null): Result = + suspend fun getLyrics( + title: String, + artist: String, + duration: Int, + album: String? = null, + ): Result = runCatching { val keyword = generateKeyword(title, artist, album) - val candidate = getLyricsCandidate(keyword, duration) - ?: throw IllegalStateException("No lyrics candidate") + val candidate = + getLyricsCandidate(keyword, duration) + ?: throw IllegalStateException("No lyrics candidate") val downloaded = downloadLyrics(candidate.id, candidate.accesskey) - val decoded = android.util.Base64.decode(downloaded.content, android.util.Base64.DEFAULT) - .decodeToString() + val decoded = + android.util.Base64 + .decode(downloaded.content, android.util.Base64.DEFAULT) + .decodeToString() decoded.normalize() } private fun getLyricsCandidate( - keyword: Keyword, duration: Int + keyword: Keyword, + duration: Int, ): SearchLyricsResponse.Candidate? { val songs = searchSongs(keyword) for (song in songs.data.info) { - if (duration == -1 || abs(song.duration - duration) <= DURATION_TOLERANCE) { + if (duration <= 0 || abs(song.duration - duration) <= DURATION_TOLERANCE) { val candidate = searchLyricsByHash(song.hash).candidates.firstOrNull() if (candidate != null) return candidate } @@ -57,39 +55,46 @@ object KuGou { } private fun searchSongs(keyword: Keyword): SearchSongResponse { - val searchQuery = buildString { - append(keyword.title) - append(" - ") - append(keyword.artist) - if (!keyword.album.isNullOrBlank()) { - append(" ") - append(keyword.album) + val searchQuery = + buildString { + append(keyword.title) + append(" - ") + append(keyword.artist) + if (!keyword.album.isNullOrBlank()) { + append(" ") + append(keyword.album) + } + } + val url = + buildString { + append("https://mobileservice.kugou.com/api/v3/search/song") + append("?version=9108&plat=0&pagesize=$PAGE_SIZE&showtype=0") + append("&keyword=${URLEncoder.encode(searchQuery, "UTF-8")}") } - } - val url = buildString { - append("https://mobileservice.kugou.com/api/v3/search/song") - append("?version=9108&plat=0&pagesize=$PAGE_SIZE&showtype=0") - append("&keyword=${URLEncoder.encode(searchQuery, "UTF-8")}") - } return executeGet(url, SearchSongResponse::class.java) ?: SearchSongResponse() } - private fun searchLyricsByKeyword(keyword: Keyword, duration: Int): SearchLyricsResponse { - val searchQuery = buildString { - append(keyword.title) - append(" - ") - append(keyword.artist) - if (!keyword.album.isNullOrBlank()) { - append(" ") - append(keyword.album) + private fun searchLyricsByKeyword( + keyword: Keyword, + duration: Int, + ): SearchLyricsResponse { + val searchQuery = + buildString { + append(keyword.title) + append(" - ") + append(keyword.artist) + if (!keyword.album.isNullOrBlank()) { + append(" ") + append(keyword.album) + } + } + val url = + buildString { + append("https://lyrics.kugou.com/search") + append("?ver=1&man=yes&client=pc") + if (duration > 0) append("&duration=${duration * 1000}") + append("&keyword=${URLEncoder.encode(searchQuery, "UTF-8")}") } - } - val url = buildString { - append("https://lyrics.kugou.com/search") - append("?ver=1&man=yes&client=pc") - if (duration != -1) append("&duration=${duration * 1000}") - append("&keyword=${URLEncoder.encode(searchQuery, "UTF-8")}") - } return executeGet(url, SearchLyricsResponse::class.java) ?: SearchLyricsResponse() } @@ -98,16 +103,24 @@ object KuGou { return executeGet(url, SearchLyricsResponse::class.java) ?: SearchLyricsResponse() } - private fun downloadLyrics(id: Long, accessKey: String): DownloadLyricsResponse { + private fun downloadLyrics( + id: Long, + accessKey: String, + ): DownloadLyricsResponse { val url = "https://lyrics.kugou.com/download?fmt=lrc&charset=utf8&client=pc&ver=1&id=$id&accesskey=$accessKey" return executeGet(url, DownloadLyricsResponse::class.java) ?: DownloadLyricsResponse() } - private fun executeGet(url: String, clazz: Class): T? { + private fun executeGet( + url: String, + clazz: Class, + ): T? { return try { - val request = Request.Builder() - .url(url) - .build() + val request = + Request + .Builder() + .url(url) + .build() client.newCall(request).execute().use { response -> if (!response.isSuccessful) { Log.w(TAG, "HTTP ${response.code} for $url") @@ -128,20 +141,34 @@ object KuGou { } private fun normalizeTitle(title: String) = - title.replace("\\(.*\\)".toRegex(), "").replace("(.*)".toRegex(), "") - .replace("「.*」".toRegex(), "").replace("『.*』".toRegex(), "") - .replace("<.*>".toRegex(), "").replace("《.*》".toRegex(), "") - .replace("〈.*〉".toRegex(), "").replace("<.*>".toRegex(), "") + 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, album: String? = null) = - Keyword(normalizeTitle(title), normalizeArtist(artist), album) + artist + .replace(", ", "、") + .replace(" & ", "、") + .replace(".", "") + .replace("和", "、") + .replace("\\(.*\\)".toRegex(), "") + .replace("(.*)".toRegex(), "") + + fun generateKeyword( + title: String, + artist: String, + album: String? = null, + ) = Keyword(normalizeTitle(title), normalizeArtist(artist), album) private fun String.normalize(): String = - lines().filter { line -> line.matches(ACCEPTED_REGEX) } + lines() + .filter { line -> line.matches(ACCEPTED_REGEX) } .let { lines -> var headCutLine = 0 for (i in min(HEAD_CUT_LIMIT, lines.lastIndex) downTo 0) { @@ -162,7 +189,14 @@ object KuGou { filteredLines.dropLast(tailCutLine).joinToString("\n") } - @Suppress("RegExpRedundantEscape") - private val ACCEPTED_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\].*".toRegex() - private val BANNED_REGEX = ".+].+[::].+".toRegex() + private companion object { + const val TAG = "KuGou" + const val PAGE_SIZE = 8 + const val HEAD_CUT_LIMIT = 30 + const val DURATION_TOLERANCE = 8 + + @Suppress("RegExpRedundantEscape") + val ACCEPTED_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\].*".toRegex() + val BANNED_REGEX = ".+].+[::].+".toRegex() + } } diff --git a/app/src/main/java/io/github/aedev/flow/data/lyrics/paxsenix/Paxsenix.kt b/app/src/main/java/io/github/aedev/flow/data/lyrics/paxsenix/Paxsenix.kt index 8b4ba17bf..ce7410517 100644 --- a/app/src/main/java/io/github/aedev/flow/data/lyrics/paxsenix/Paxsenix.kt +++ b/app/src/main/java/io/github/aedev/flow/data/lyrics/paxsenix/Paxsenix.kt @@ -4,61 +4,31 @@ package io.github.aedev.flow.data.lyrics.paxsenix -import android.content.Context import android.util.Log import com.google.gson.Gson +import io.github.aedev.flow.BuildConfig +import io.github.aedev.flow.data.lyrics.LyricsMatching import io.github.aedev.flow.data.lyrics.TTMLParser import io.github.aedev.flow.data.lyrics.paxsenix.models.AppleMusicSearchResponse import io.github.aedev.flow.data.lyrics.paxsenix.models.LyricsResponse import io.github.aedev.flow.data.lyrics.paxsenix.models.SearchResult -import io.github.aedev.flow.network.AppProxyManager import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import java.net.URLEncoder import java.util.Locale -import java.util.concurrent.TimeUnit import kotlin.math.abs -object Paxsenix { - private const val TAG = "Paxsenix" - private const val PAXSENIX_BASE = "https://lyrics.paxsenix.org" - private const val APPLE_MUSIC_API_BASE = "https://amp-api.music.apple.com/v1/catalog/us" - private const val TITLE_NOISE_WORDS = - "official|video|audio|lyrics|lyric|visualizer|hd|hq|4k|remaster|remix|live|acoustic|version|edit|extended|radio|clean|explicit" - - @Volatile - private var initialized = false - private var appVersion: String = "Unknown" - - private val client: OkHttpClient - get() = - AppProxyManager - .applyTo(OkHttpClient.Builder()) - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(15, TimeUnit.SECONDS) - .build() - +class Paxsenix( + private val client: OkHttpClient, +) { private val gson = Gson() - @Volatile - private var tokenManager: AppleTokenManager? = null - - fun init(context: Context) { - if (initialized) return - synchronized(this) { - if (initialized) return - appVersion = - try { - context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "Unknown" - } catch (e: Exception) { - "Unknown" - } - tokenManager = AppleTokenManager() - initialized = true - } - } + private val tokenManager = AppleTokenManager(client) private val titleCleanupPatterns = listOf( @@ -144,13 +114,19 @@ object Paxsenix { } private suspend fun search(query: String): List { + // The public iTunes Search needs no token and finds the same Apple Music ids; the token + // scraped from Apple's web player is the fallback. + runCatching { searchItunes(query) } + .onFailure { Log.w(TAG, "iTunes search error: ${it.message}") } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + ?.let { return it } return try { - val tm = tokenManager ?: return emptyList() - val token = tm.getToken() ?: return emptyList() + val token = tokenManager.getToken() ?: return emptyList() val results = searchWithToken(token, query) if (results == null) { - tm.clearToken() - val newToken = tm.getToken() ?: return emptyList() + tokenManager.clearToken() + val newToken = tokenManager.getToken() ?: return emptyList() searchWithToken(newToken, query) ?: emptyList() } else { results @@ -161,6 +137,29 @@ object Paxsenix { } } + private fun searchItunes(query: String): List { + val url = + ITUNES_SEARCH_URL + .toHttpUrl() + .newBuilder() + .addQueryParameter("term", query) + .addQueryParameter("media", "music") + .addQueryParameter("entity", "song") + .addQueryParameter("limit", "25") + .build() + val request = + Request + .Builder() + .url(url) + .header("Accept", "application/json") + .build() + val body = + client.newCall(request).execute().use { response -> + if (response.isSuccessful) response.body.string() else null + } ?: return emptyList() + return parseItunesSearch(body) + } + private fun searchWithToken( token: String, query: String, @@ -242,7 +241,6 @@ object Paxsenix { artist: String, duration: Int, ): List> { - val durationMs = duration * 1000 val cleanupRegex = Regex("""\s*\(.*?\)|\s*\[.*?\]""") val cleanedTitle = title.replace(cleanupRegex, "").lowercase().trim() val cleanedArtist = cleanArtist(artist).lowercase() @@ -250,17 +248,18 @@ object Paxsenix { val targetIsRemix = title.contains("remix", ignoreCase = true) return results + .filter { LyricsMatching.sameVersion(title, it.displayName) } .map { result -> var score = 0.0 val resultTitle = result.displayName val resultArtist = result.displayArtist - result.duration?.let { d -> - val diff = abs(d - durationMs) + result.duration?.takeIf { duration > 0 }?.let { d -> + val diff = abs(d - duration) when { - diff <= 2000 -> score += 100 - diff <= 5000 -> score += 50 - diff <= 10000 -> score += 10 + diff <= 2 -> score += 100 + diff <= 5 -> score += 50 + diff <= 10 -> score += 10 else -> score -= 50 } } @@ -301,7 +300,7 @@ object Paxsenix { Request .Builder() .url(url) - .header("User-Agent", "Flow/$appVersion") + .header("User-Agent", "Flow/${BuildConfig.VERSION_NAME}") .build() val response = client.newCall(request).execute() @@ -393,7 +392,9 @@ object Paxsenix { } } - private class AppleTokenManager { + private class AppleTokenManager( + private val httpClient: OkHttpClient, + ) { @Volatile private var cachedToken: String? = null private val mutex = Mutex() @@ -402,14 +403,6 @@ object Paxsenix { mutex.withLock { cachedToken?.let { return it } try { - val httpClient = - AppProxyManager - .applyTo(OkHttpClient.Builder()) - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS) - .followRedirects(true) - .build() - val mainRequest = Request .Builder() @@ -449,4 +442,44 @@ object Paxsenix { cachedToken = null } } + + @Serializable + private data class ItunesSearch( + val results: List = emptyList(), + ) + + @Serializable + private data class ItunesSong( + val trackId: Long = 0, + val trackName: String? = null, + val artistName: String? = null, + val collectionName: String? = null, + val trackTimeMillis: Long? = null, + ) + + internal companion object { + private const val TAG = "Paxsenix" + private const val ITUNES_SEARCH_URL = "https://itunes.apple.com/search" + private val json = Json { ignoreUnknownKeys = true } + + fun parseItunesSearch(body: String): List = + json + .decodeFromString(body) + .results + .filter { it.trackId > 0 && !it.trackName.isNullOrBlank() } + .map { song -> + SearchResult( + id = song.trackId.toString(), + trackName = song.trackName, + artistName = song.artistName, + albumName = song.collectionName, + duration = song.trackTimeMillis?.div(1000)?.toInt(), + ) + } + + private const val PAXSENIX_BASE = "https://lyrics.paxsenix.org" + private const val APPLE_MUSIC_API_BASE = "https://amp-api.music.apple.com/v1/catalog/us" + private const val TITLE_NOISE_WORDS = + "official|video|audio|lyrics|lyric|visualizer|hd|hq|4k|remaster|remix|live|acoustic|version|edit|extended|radio|clean|explicit" + } } diff --git a/app/src/main/java/io/github/aedev/flow/data/video/downloader/work/DownloadLyrics.kt b/app/src/main/java/io/github/aedev/flow/data/video/downloader/work/DownloadLyrics.kt index 85a06dc89..22aad2ef8 100644 --- a/app/src/main/java/io/github/aedev/flow/data/video/downloader/work/DownloadLyrics.kt +++ b/app/src/main/java/io/github/aedev/flow/data/video/downloader/work/DownloadLyrics.kt @@ -1,8 +1,6 @@ package io.github.aedev.flow.data.video.downloader.work -import android.content.Context import android.util.Log -import dagger.hilt.android.qualifiers.ApplicationContext import io.github.aedev.flow.data.local.dao.DownloadDao import io.github.aedev.flow.data.lyrics.LyricsHelper import io.github.aedev.flow.data.lyrics.lrcText @@ -19,47 +17,43 @@ import javax.inject.Singleton * retag sweep over older downloads never calls this, which would be one lookup per file. */ @Singleton -class DownloadLyrics internal constructor( - private val lyricsHelper: LyricsHelper, - private val downloadDao: DownloadDao, -) { +class DownloadLyrics @Inject constructor( - @ApplicationContext context: Context, - downloadDao: DownloadDao, - ) : this(LyricsHelper(context), downloadDao) + private val lyricsHelper: LyricsHelper, + private val downloadDao: DownloadDao, + ) { + suspend fun addTo(request: DownloadRequest): DownloadRequest { + val tags = request.tags + if (!request.isMusic || tags.lyrics != null) return request + val entries = + try { + withTimeoutOrNull(LOOKUP_TIMEOUT_MS) { + lyricsHelper.getLyrics( + videoId = request.videoId, + title = tags.title, + artist = tags.displayArtist().orEmpty(), + duration = request.durationSeconds, + album = tags.album, + ) + }?.first + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w(TAG, "${request.videoId}: lyrics unavailable", e) + null + } + if (entries.isNullOrEmpty()) return request + val synced = lyricsHelper.entriesAreSynced(entries) + val text = lrcText(if (synced) entries else emptyList(), entries.joinToString("\n") { it.text }).trim() + if (text.isEmpty()) return request + val withLyrics = request.copy(tags = tags.copy(lyrics = text)) + downloadDao.updateRequest(request.videoId, withLyrics.encode()) + return withLyrics + } - suspend fun addTo(request: DownloadRequest): DownloadRequest { - val tags = request.tags - if (!request.isMusic || tags.lyrics != null) return request - val entries = - try { - withTimeoutOrNull(LOOKUP_TIMEOUT_MS) { - lyricsHelper.getLyrics( - videoId = request.videoId, - title = tags.title, - artist = tags.displayArtist().orEmpty(), - duration = request.durationSeconds, - album = tags.album, - ) - }?.first - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "${request.videoId}: lyrics unavailable", e) - null - } - if (entries.isNullOrEmpty()) return request - val synced = lyricsHelper.entriesAreSynced(entries) - val text = lrcText(if (synced) entries else emptyList(), entries.joinToString("\n") { it.text }).trim() - if (text.isEmpty()) return request - val withLyrics = request.copy(tags = tags.copy(lyrics = text)) - downloadDao.updateRequest(request.videoId, withLyrics.encode()) - return withLyrics + private companion object { + const val TAG = "DownloadLyrics" + const val LOOKUP_TIMEOUT_MS = 30_000L + } } - - private companion object { - const val TAG = "DownloadLyrics" - const val LOOKUP_TIMEOUT_MS = 30_000L - } -} diff --git a/app/src/main/java/io/github/aedev/flow/player/MusicMediaItems.kt b/app/src/main/java/io/github/aedev/flow/player/MusicMediaItems.kt index 7b3fd69a1..b9b3a245c 100644 --- a/app/src/main/java/io/github/aedev/flow/player/MusicMediaItems.kt +++ b/app/src/main/java/io/github/aedev/flow/player/MusicMediaItems.kt @@ -27,11 +27,14 @@ internal fun musicMediaItem( .setTitle(track.title) .setArtist(track.artist) .setArtworkUri(Uri.parse(track.highResThumbnailUrl)) + .setDurationMs(track.duration.takeIf { it > 0 }?.let { it * MILLIS_PER_SECOND }) .build(), ) if (useCacheKey) builder.setCustomCacheKey(track.videoId) return builder.build() } +private const val MILLIS_PER_SECOND = 1000L + /** A song as the audio it streams, or its MediaStore file when it is on the device. */ internal fun musicAudioUri(videoId: String): Uri = LocalMediaIds.audioUri(videoId) ?: MusicVideoItems.streamUri(videoId, showsVideo = false) diff --git a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt index d37a342d4..ca6c22874 100644 --- a/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt +++ b/app/src/main/java/io/github/aedev/flow/service/Media3MusicService.kt @@ -242,6 +242,9 @@ class Media3MusicService : MediaLibraryService() { @Inject lateinit var queuePersistence: QueuePersistence + @Inject + lateinit var lyricsHelper: io.github.aedev.flow.data.lyrics.LyricsHelper + @OptIn(UnstableApi::class) override fun onCreate() { super.onCreate() @@ -483,10 +486,8 @@ class Media3MusicService : MediaLibraryService() { lifecycleScope.launch(Dispatchers.IO) { try { Log.d(TAG, "Pre-warming lyrics cache in background for: $videoId - \"$title\"") - val helper = - io.github.aedev.flow.data.lyrics - .LyricsHelper(this@Media3MusicService) - helper.getLyrics(videoId, title, artist, 180, null, this@Media3MusicService) + val durationSeconds = ((item.mediaMetadata.durationMs ?: 0L) / 1000L).toInt() + lyricsHelper.getLyrics(videoId, title, artist, durationSeconds, null) } catch (e: Exception) { Log.w(TAG, "Lyrics pre-warm background task encountered error: ${e.message}") } diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/music/MusicPlayerViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/music/MusicPlayerViewModel.kt index 1ad84c935..c2fb06798 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/music/MusicPlayerViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/music/MusicPlayerViewModel.kt @@ -50,6 +50,7 @@ class MusicPlayerViewModel private val viewHistory: ViewHistory, private val musicBrain: MusicBrainEngine, private val scrobbler: Scrobbler, + private val lyricsHelper: LyricsHelper, localLyrics: LocalLyricsReader, ) : ViewModel() { private val _uiState = MutableStateFlow(MusicPlayerUiState()) @@ -63,7 +64,6 @@ class MusicPlayerViewModel private val _currentPositionMs = MutableStateFlow(0L) val currentPositionMs: StateFlow = _currentPositionMs.asStateFlow() - private val lyricsHelper = LyricsHelper(context) private val playerPreferences = PlayerPreferences(context) private var isInitialized = false diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/LyricsProvidersSheet.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/LyricsProvidersSheet.kt index ad06166e2..e196ef16a 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/LyricsProvidersSheet.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/LyricsProvidersSheet.kt @@ -1,6 +1,8 @@ package io.github.aedev.flow.ui.screens.settings.playback +import androidx.annotation.StringRes import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets @@ -42,6 +44,7 @@ import io.github.aedev.flow.ui.components.shared.rememberFlowSheetState import sh.calvin.reorderable.ReorderableColumn private val RowPadding = 24.dp +private val RowVerticalPadding = 8.dp private val SheetBottomSpacing = 16.dp /** @@ -131,11 +134,20 @@ internal fun LyricsProvidersSheet( .draggableHandle(), contentAlignment = Alignment.Center, ) { ReorderHandle() } - Text( - text = provider.name, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.weight(1f), - ) + val label = ProviderLabels[provider.name] + Column(modifier = Modifier.weight(1f).padding(vertical = RowVerticalPadding)) { + Text( + text = label?.let { stringResource(it.name) } ?: provider.name, + style = MaterialTheme.typography.bodyLarge, + ) + label?.let { + Text( + text = stringResource(it.description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } FlowSwitch( checked = provider.enabled, onCheckedChange = { viewModel.setLyricsProviderEnabled(provider.name, it) }, @@ -147,3 +159,22 @@ internal fun LyricsProvidersSheet( } } } + +private class ProviderLabel( + @param:StringRes val name: Int, + @param:StringRes val description: Int, +) + +private val ProviderLabels = + mapOf( + "BetterLyrics" to ProviderLabel(R.string.lyrics_provider_better_lyrics, R.string.lyrics_provider_better_lyrics_desc), + "LrcRed" to ProviderLabel(R.string.lyrics_provider_lrc_red, R.string.lyrics_provider_lrc_red_desc), + "LyricsPlus" to ProviderLabel(R.string.lyrics_provider_lyrics_plus, R.string.lyrics_provider_lyrics_plus_desc), + "SimpMusic" to ProviderLabel(R.string.lyrics_provider_simpmusic, R.string.lyrics_provider_simpmusic_desc), + "KuGou" to ProviderLabel(R.string.lyrics_provider_kugou, R.string.lyrics_provider_kugou_desc), + "Paxsenix" to ProviderLabel(R.string.lyrics_provider_paxsenix, R.string.lyrics_provider_paxsenix_desc), + "LrcLib" to ProviderLabel(R.string.lyrics_provider_lrclib, R.string.lyrics_provider_lrclib_desc), + "Unison" to ProviderLabel(R.string.lyrics_provider_unison, R.string.lyrics_provider_unison_desc), + "YouTubeSubtitle" to ProviderLabel(R.string.lyrics_provider_youtube_captions, R.string.lyrics_provider_youtube_captions_desc), + "YouTube" to ProviderLabel(R.string.lyrics_provider_youtube_music, R.string.lyrics_provider_youtube_music_desc), + ) diff --git a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsViewModel.kt b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsViewModel.kt index b45ce5e06..6d452756e 100644 --- a/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsViewModel.kt +++ b/app/src/main/java/io/github/aedev/flow/ui/screens/settings/playback/PlaybackSettingsViewModel.kt @@ -8,7 +8,7 @@ import io.github.aedev.flow.data.local.PlayerOverlayPreferences import io.github.aedev.flow.data.local.PlayerPreferences import io.github.aedev.flow.data.local.PlayerRelatedCardStyle import io.github.aedev.flow.data.local.SpeedSwipeSide -import io.github.aedev.flow.data.lyrics.LyricsProviderRegistry +import io.github.aedev.flow.data.lyrics.LyricsProviderOrder import io.github.aedev.flow.player.stream.CaptionTrackResolver import io.github.aedev.flow.ui.screens.settings.SettingsViewModel import kotlinx.coroutines.flow.combine @@ -29,7 +29,6 @@ class PlaybackSettingsViewModel private val preferences: PlayerPreferences, ) : SettingsViewModel() { private val overlay = PlayerOverlayPreferences() - private val lyricsRegistry = LyricsProviderRegistry.default() val backgroundPlay = preferences.backgroundPlayEnabled.asState(DEFAULT_BACKGROUND_PLAY_ENABLED) val autoplay = preferences.autoplayEnabled.asState(true) @@ -104,8 +103,8 @@ class PlaybackSettingsViewModel val pauseMusicWhenMuted = preferences.pauseMusicWhenMuted.asState(false) val lyricsProviders = combine(preferences.lyricsProviderOrder, preferences.allLyricsProviderEnabledStates()) { order, enabled -> - lyricsRegistry.getOrderedProviders(order).map { LyricsProviderState(it.name, enabled[it.name] ?: true) } - }.asState(lyricsRegistry.getOrderedProviders("").map { LyricsProviderState(it.name, true) }) + LyricsProviderOrder.resolve(order).map { LyricsProviderState(it, enabled[it] ?: true) } + }.asState(LyricsProviderOrder.DEFAULT.map { LyricsProviderState(it, true) }) fun setBackgroundPlay(value: Boolean) = write { preferences.setBackgroundPlayEnabled(value) } @@ -242,8 +241,7 @@ class PlaybackSettingsViewModel enabled: Boolean, ) = write { preferences.setLyricsProviderEnabled(name, enabled) } - fun setLyricsProviderOrder(names: List) = - write { preferences.setLyricsProviderOrder(lyricsRegistry.serializeProviderOrder(names)) } + fun setLyricsProviderOrder(names: List) = write { preferences.setLyricsProviderOrder(LyricsProviderOrder.serialize(names)) } companion object { const val ORIGINAL_AUDIO = "original" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7474c7a64..c6864c87c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -983,6 +983,22 @@ Better Lyrics SimpMusic LyricsPlus + lrc.red + KuGou + Apple Music (Paxsenix) + Unison + YouTube captions + YouTube Music + Word sync · Apple Music lyrics + Word sync · matched by recording + Word sync + Word sync · matched by video + Line sync · strong for Chinese songs + Word sync + Line sync · open database + Community lyrics · small catalogue + Line sync · the video\'s captions + Plain text · the Lyrics tab System Default Follows your device between light and dark. diff --git a/app/src/test/java/io/github/aedev/flow/data/lyrics/InFlightRunsTest.kt b/app/src/test/java/io/github/aedev/flow/data/lyrics/InFlightRunsTest.kt new file mode 100644 index 000000000..61a8b15ee --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/data/lyrics/InFlightRunsTest.kt @@ -0,0 +1,103 @@ +package io.github.aedev.flow.data.lyrics + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class InFlightRunsTest { + @Test + fun `a second caller waits for the running fetch instead of starting another`() = + runTest { + val runs = InFlightRuns(backgroundScope) + val gate = CompletableDeferred() + var started = 0 + + val first = + async { + runs.join("v") { + started++ + gate.await() + } + } + val second = + async { + runs.join("v") { + started++ + "second" + } + } + runCurrent() + gate.complete("first") + + assertThat(first.await()).isEqualTo("first") + assertThat(second.await()).isEqualTo("first") + assertThat(started).isEqualTo(1) + } + + @Test + fun `a finished key runs again on the next call`() = + runTest { + val runs = InFlightRuns(backgroundScope) + var started = 0 + + runs.join("v") { ++started } + val again = runs.join("v") { ++started } + + assertThat(again).isEqualTo(2) + } + + @Test + fun `one caller leaving keeps the run going for the other`() = + runTest { + val runs = InFlightRuns(backgroundScope) + val gate = CompletableDeferred() + + val leaving = launch { runs.join("v") { gate.await() } } + val staying = async { runs.join("v") { "unused" } } + runCurrent() + leaving.cancel() + runCurrent() + gate.complete("lyrics") + + assertThat(staying.await()).isEqualTo("lyrics") + } + + @Test + fun `the run stops once every caller has left`() = + runTest { + val runs = InFlightRuns(backgroundScope) + var cancelled = false + + val only = + launch { + runs.join("v") { + try { + awaitCancellation() + } finally { + cancelled = true + } + } + } + runCurrent() + only.cancel() + runCurrent() + + assertThat(cancelled).isTrue() + } + + @Test + fun `different keys run separately`() = + runTest { + val runs = InFlightRuns(backgroundScope) + + val a = async { runs.join("a") { "A" } } + val b = async { runs.join("b") { "B" } } + + assertThat(a.await() to b.await()).isEqualTo("A" to "B") + } +} diff --git a/app/src/test/java/io/github/aedev/flow/data/lyrics/LrcRedLyricsProviderTest.kt b/app/src/test/java/io/github/aedev/flow/data/lyrics/LrcRedLyricsProviderTest.kt new file mode 100644 index 000000000..9e9418783 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/data/lyrics/LrcRedLyricsProviderTest.kt @@ -0,0 +1,43 @@ +package io.github.aedev.flow.data.lyrics + +import com.google.common.truth.Truth.assertThat +import io.github.aedev.flow.data.lyrics.LyricsMatching.Song +import org.junit.Test + +class LrcRedLyricsProviderTest { + private fun hit( + title: String = "Song", + artist: String = "Band", + duration: Int = 200, + timing: String = "word", + url: String = "https://lrc.red/s/XX0000000001.ttml", + ) = + """{"track_name":"$title","artist_name":"$artist","duration":$duration,"timing_type":"$timing","lyricsUrl":"$url","isrc":"XX0000000001"}""" + + private fun hits(vararg items: String) = LrcRedLyricsProvider.parseSearch("""{"results":[${items.joinToString(",")}],"extra":1}""") + + @Test + fun `search results parse and ignore unknown fields`() { + val parsed = hits(hit()) + + assertThat(parsed.single().trackName).isEqualTo("Song") + assertThat(parsed.single().lyricsUrl).isEqualTo("https://lrc.red/s/XX0000000001.ttml") + } + + @Test + fun `a word-timed file wins over a line-timed one of the same recording`() { + val picked = LrcRedLyricsProvider.pick(hits(hit(timing = "line"), hit(timing = "word", duration = 201)), Song("Song", "Band", 200)) + + assertThat(picked?.timingType).isEqualTo("word") + } + + @Test + fun `the studio cut is not taken for a live request`() { + assertThat(LrcRedLyricsProvider.pick(hits(hit()), Song("Song (Live)", "Band", 200))).isNull() + } + + @Test + fun `a document that is not served over https is never fetched`() { + assertThat(LrcRedLyricsProvider.pick(hits(hit(url = "http://lrc.red/s/a.ttml")), Song("Song", "Band", 200))).isNull() + } +} diff --git a/app/src/test/java/io/github/aedev/flow/data/lyrics/LyricsMatchingTest.kt b/app/src/test/java/io/github/aedev/flow/data/lyrics/LyricsMatchingTest.kt new file mode 100644 index 000000000..7cf672058 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/data/lyrics/LyricsMatchingTest.kt @@ -0,0 +1,85 @@ +package io.github.aedev.flow.data.lyrics + +import com.google.common.truth.Truth.assertThat +import io.github.aedev.flow.data.lyrics.LyricsMatching.Song +import org.junit.Test + +class LyricsMatchingTest { + @Test + fun `the search keeps version tags and drops credits and packaging`() { + assertThat(LyricsMatching.cleanTitle("Song (Live)")).isEqualTo("Song (Live)") + assertThat(LyricsMatching.cleanTitle("Song (Acoustic Version) [Official Video]")).isEqualTo("Song (Acoustic Version)") + assertThat(LyricsMatching.cleanTitle("Song (feat. Someone)")).isEqualTo("Song") + assertThat(LyricsMatching.cleanTitle("Song feat. Someone")).isEqualTo("Song") + assertThat(LyricsMatching.cleanTitle("Song - Official Music Video")).isEqualTo("Song") + assertThat(LyricsMatching.cleanTitle("Video Killed the Radio Star")).isEqualTo("Video Killed the Radio Star") + } + + @Test + fun `the first credited artist is searched`() { + assertThat(LyricsMatching.cleanArtist("The Weeknd - Topic")).isEqualTo("The Weeknd") + assertThat(LyricsMatching.cleanArtist("Calvin Harris feat. Dua Lipa")).isEqualTo("Calvin Harris") + assertThat(LyricsMatching.cleanArtist("Simon & Garfunkel")).isEqualTo("Simon") + } + + @Test + fun `version tags come from brackets and a trailing dash part, as whole words`() { + assertThat(LyricsMatching.versionTags("Song (Live at Wembley)")).containsExactly("live") + assertThat(LyricsMatching.versionTags("Song - Acoustic")).containsExactly("acoustic") + assertThat(LyricsMatching.versionTags("Song (Sped Up)")).containsExactly("sped") + assertThat(LyricsMatching.versionTags("Song (2011 Remaster)")).isEmpty() + assertThat(LyricsMatching.versionTags("Stayin' Alive")).isEmpty() + assertThat(LyricsMatching.versionTags("Song (Alive Edit)")).isEmpty() + } + + @Test + fun `a live request never takes the studio cut and the other way round`() { + assertThat(LyricsMatching.sameVersion("Song (Live)", "Song")).isFalse() + assertThat(LyricsMatching.sameVersion("Song", "Song (Remix)")).isFalse() + assertThat(LyricsMatching.sameVersion("Song (Live)", "Song (Live at Wembley)")).isTrue() + assertThat(LyricsMatching.sameVersion("Song", "Song (2011 Remaster)")).isTrue() + } + + @Test + fun `exact title and artist pass the floor, a fuzzy title needs a close duration too`() { + val request = Song("Blinding Lights", "The Weeknd", 200) + + assertThat(LyricsMatching.score(request, Song("Blinding Lights", "The Weeknd", 0))).isAtLeast(LyricsMatching.MIN_SCORE) + assertThat(LyricsMatching.score(request, Song("Blinding Lights Tonight", "The Weeknd", 0))).isLessThan(LyricsMatching.MIN_SCORE) + assertThat(LyricsMatching.score(request, Song("Blinding Lights", "Someone Else", 260))).isLessThan(LyricsMatching.MIN_SCORE) + } + + @Test + fun `a featured artist still counts as the artist`() { + val request = Song("Starboy", "The Weeknd", 230) + + assertThat(LyricsMatching.score(request, Song("Starboy", "The Weeknd & Daft Punk", 230))).isAtLeast(LyricsMatching.MIN_SCORE) + } + + @Test + fun `the best candidate is the same version, then highest score, then preferred, then closest duration`() { + val request = Song("Song (Live)", "Band", 240) + val candidates = + listOf( + Song("Song", "Band", 240), + Song("Song (Live)", "Band", 250), + Song("Song (Live)", "Band", 241), + Song("Song (Live)", "Other", 240), + ) + + assertThat(LyricsMatching.best(candidates, request, { it })).isEqualTo(candidates[2]) + } + + @Test + fun `a preferred candidate wins a tie`() { + val request = Song("Song", "Band", 0) + val candidates = listOf(Song("Song", "Band", 200) to false, Song("Song", "Band", 201) to true) + + assertThat(LyricsMatching.best(candidates, request, { it.first }, { it.second })).isEqualTo(candidates[1]) + } + + @Test + fun `nothing is picked when no candidate clears the floor`() { + assertThat(LyricsMatching.best(listOf(Song("Other", "Band", 200)), Song("Song", "Band", 200), { it })).isNull() + } +} diff --git a/app/src/test/java/io/github/aedev/flow/data/lyrics/LyricsProviderOrderTest.kt b/app/src/test/java/io/github/aedev/flow/data/lyrics/LyricsProviderOrderTest.kt new file mode 100644 index 000000000..6db6e920a --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/data/lyrics/LyricsProviderOrderTest.kt @@ -0,0 +1,48 @@ +package io.github.aedev.flow.data.lyrics + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class LyricsProviderOrderTest { + @Test + fun `nothing saved means the default order`() { + assertThat(LyricsProviderOrder.resolve("")).isEqualTo(LyricsProviderOrder.DEFAULT) + } + + @Test + fun `a full saved order is kept as it is`() { + val saved = LyricsProviderOrder.DEFAULT.reversed() + + assertThat(LyricsProviderOrder.resolve(saved.joinToString(","))).isEqualTo(saved) + } + + @Test + fun `an order saved before lrc red existed gets it right after BetterLyrics`() { + val saved = listOf("LyricsPlus", "BetterLyrics", "KuGou", "SimpMusic", "Paxsenix", "LrcLib", "YouTubeSubtitle", "YouTube") + + val resolved = LyricsProviderOrder.resolve(saved.joinToString(",")) + + assertThat(resolved.indexOf("LrcRed")).isEqualTo(resolved.indexOf("BetterLyrics") + 1) + assertThat(resolved.filter { it in saved }).isEqualTo(saved) + } + + @Test + fun `a missing first provider goes to the top`() { + val saved = LyricsProviderOrder.DEFAULT.drop(1) + + assertThat(LyricsProviderOrder.resolve(saved.joinToString(","))).isEqualTo(LyricsProviderOrder.DEFAULT) + } + + @Test + fun `unknown and repeated names are dropped`() { + val resolved = LyricsProviderOrder.resolve("Gone, KuGou,KuGou," + LyricsProviderOrder.DEFAULT.joinToString(",")) + + assertThat(resolved.first()).isEqualTo("KuGou") + assertThat(resolved).containsExactlyElementsIn(LyricsProviderOrder.DEFAULT) + } + + @Test + fun `serialize keeps known names once`() { + assertThat(LyricsProviderOrder.serialize(listOf("KuGou", "Gone", "KuGou", "LrcLib"))).isEqualTo("KuGou,LrcLib") + } +} diff --git a/app/src/test/java/io/github/aedev/flow/data/lyrics/PaxsenixItunesSearchTest.kt b/app/src/test/java/io/github/aedev/flow/data/lyrics/PaxsenixItunesSearchTest.kt new file mode 100644 index 000000000..e7bed515e --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/data/lyrics/PaxsenixItunesSearchTest.kt @@ -0,0 +1,24 @@ +package io.github.aedev.flow.data.lyrics + +import com.google.common.truth.Truth.assertThat +import io.github.aedev.flow.data.lyrics.paxsenix.Paxsenix +import org.junit.Test + +class PaxsenixItunesSearchTest { + @Test + fun `itunes songs become search results with seconds and the apple music id`() { + val body = + """{"resultCount":2,"results":[ + {"wrapperType":"track","trackId":1488408568,"trackName":"Blinding Lights","artistName":"The Weeknd","collectionName":"After Hours","trackTimeMillis":200040}, + {"wrapperType":"track","trackId":0,"trackName":"No id"} + ]}""" + + val result = Paxsenix.parseItunesSearch(body).single() + + assertThat(result.id).isEqualTo("1488408568") + assertThat(result.displayName).isEqualTo("Blinding Lights") + assertThat(result.displayArtist).isEqualTo("The Weeknd") + assertThat(result.albumName).isEqualTo("After Hours") + assertThat(result.duration).isEqualTo(200) + } +} diff --git a/app/src/test/java/io/github/aedev/flow/data/lyrics/TTMLParserTimingTest.kt b/app/src/test/java/io/github/aedev/flow/data/lyrics/TTMLParserTimingTest.kt new file mode 100644 index 000000000..dd2957962 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/data/lyrics/TTMLParserTimingTest.kt @@ -0,0 +1,41 @@ +package io.github.aedev.flow.data.lyrics + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class TTMLParserTimingTest { + private fun ttml(vararg lines: Pair) = + """
""" + + lines.joinToString("") { (begin, end) -> + """

Word

""" + } + + "
" + + @Test + fun `apple style bare seconds and minute times are read`() { + val entries = TTMLParser.parseTTMLToLyricsEntries(ttml("27.395" to "28.100", "1:02.345" to "1:03.000", "3:12.818" to "3:14.043")) + + assertThat(entries.map { it.time }).containsExactly(27_395L, 62_345L, 192_818L).inOrder() + assertThat( + entries + .first() + .words + ?.single() + ?.endTime, + ).isEqualTo(28_100L) + } + + @Test + fun `ttml offset and clock times are still read`() { + val entries = TTMLParser.parseTTMLToLyricsEntries(ttml("9.446s" to "10s", "12500ms" to "13000ms", "00:01:02.5" to "00:01:03.25")) + + assertThat(entries.map { it.time }).containsExactly(9_446L, 12_500L, 62_500L).inOrder() + assertThat( + entries + .last() + .words + ?.single() + ?.endTime, + ).isEqualTo(63_250L) + } +} diff --git a/app/src/test/java/io/github/aedev/flow/data/lyrics/UnisonLyricsProviderTest.kt b/app/src/test/java/io/github/aedev/flow/data/lyrics/UnisonLyricsProviderTest.kt new file mode 100644 index 000000000..3054ca118 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/data/lyrics/UnisonLyricsProviderTest.kt @@ -0,0 +1,41 @@ +package io.github.aedev.flow.data.lyrics + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class UnisonLyricsProviderTest { + private fun body( + lyrics: String, + format: String, + syncType: String, + success: Boolean = true, + ) = """{"success":$success,"data":{"lyrics":"$lyrics","format":"$format","syncType":"$syncType","id":7}}""" + + @Test + fun `line-synced lrc becomes timed lines`() { + val entries = UnisonLyricsProvider.parse(body("[00:01.00]First\\n[00:12.50]Second", "lrc", "linesync")) + + assertThat(entries.map { it.time to it.text }).containsExactly(1_000L to "First", 12_500L to "Second").inOrder() + } + + @Test + fun `word stamps in lrc become word timings`() { + val entries = UnisonLyricsProvider.parse(body("[00:01.00]<00:01.00>Hello <00:01.50>there", "lrc", "wordsync")) + + assertThat(entries.single().words?.map { it.text }).containsExactly("Hello", "there").inOrder() + } + + @Test + fun `plain text stays one unsynced entry`() { + val entries = UnisonLyricsProvider.parse(body("First\\nSecond", "lrc", "plain")) + + assertThat(entries.single().time).isEqualTo(0L) + assertThat(entries.single().text).isEqualTo("First\nSecond") + } + + @Test + fun `an unsuccessful answer gives nothing`() { + assertThat(UnisonLyricsProvider.parse(body("[00:01.00]First", "lrc", "linesync", success = false))).isEmpty() + assertThat(UnisonLyricsProvider.parse("""{"success":false,"error":"Lyrics not found","code":"NOT_FOUND"}""")).isEmpty() + } +} diff --git a/app/src/test/java/io/github/aedev/flow/player/MusicMediaItemsTest.kt b/app/src/test/java/io/github/aedev/flow/player/MusicMediaItemsTest.kt new file mode 100644 index 000000000..2faee58b5 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/player/MusicMediaItemsTest.kt @@ -0,0 +1,26 @@ +package io.github.aedev.flow.player + +import android.net.Uri +import com.google.common.truth.Truth.assertThat +import io.github.aedev.flow.data.music.model.MusicTrack +import io.mockk.mockk +import org.junit.Test + +class MusicMediaItemsTest { + private fun track(duration: Int) = MusicTrack(videoId = "v", title = "Song", artist = "Artist", thumbnailUrl = "", duration = duration) + + @Test + fun `a song's item carries its duration, so the queue read back from the player keeps it`() { + val item = musicMediaItem(track(duration = 200), mockk(), useCacheKey = false) + + assertThat(item.mediaMetadata.durationMs).isEqualTo(200_000L) + assertThat(adoptedQueue(listOf(item)).single().duration).isEqualTo(200) + } + + @Test + fun `an unknown duration stays unknown instead of zero`() { + val item = musicMediaItem(track(duration = 0), mockk(), useCacheKey = false) + + assertThat(item.mediaMetadata.durationMs).isNull() + } +}