mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
support sidecar and embedded lyrics
This commit is contained in:
@@ -22,4 +22,5 @@ dependencies {
|
||||
// MetadataRetriever — parses ID3/Vorbis/MP4 container tags (ReplayGain) without
|
||||
// decoding audio. Transitively brings exoplayer-extractor (the frame classes).
|
||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.19.0'
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
}
|
||||
|
||||
+27
-16
@@ -14,6 +14,7 @@ import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MetadataRetriever
|
||||
import com.google.android.exoplayer2.metadata.id3.BinaryFrame
|
||||
import com.google.android.exoplayer2.metadata.id3.InternalFrame
|
||||
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
|
||||
import com.google.android.exoplayer2.metadata.flac.VorbisComment
|
||||
@@ -132,10 +133,9 @@ class AstraLibraryScannerModule : Module() {
|
||||
withContext(Dispatchers.IO) { readSidecarLyrics(uri) }
|
||||
}
|
||||
|
||||
// Embedded lyrics from container tags (Vorbis LYRICS/UNSYNCEDLYRICS for
|
||||
// FLAC/Ogg/Opus, plus TXXX/MP4 lyric atoms), metadata-only (no PCM decode).
|
||||
// Returns { text } or null. ID3 USLT/SYLT are not decoded by ExoPlayer and are
|
||||
// intentionally out of scope here.
|
||||
// Embedded lyrics from container tags (Vorbis comments, ID3 USLT/SYLT/TXXX,
|
||||
// and MP4 lyric atoms), metadata-only (no PCM decode). Distinguishing a true
|
||||
// miss from an I/O failure lets JS invalidate only genuinely stale cache rows.
|
||||
AsyncFunction("readEmbeddedLyrics") Coroutine { uri: String ->
|
||||
withContext(Dispatchers.IO) { readEmbeddedLyrics(uri) }
|
||||
}
|
||||
@@ -341,26 +341,23 @@ class AstraLibraryScannerModule : Module() {
|
||||
|
||||
/**
|
||||
* Read embedded lyrics from container metadata via ExoPlayer's MetadataRetriever.
|
||||
* Covers Vorbis LYRICS/UNSYNCEDLYRICS (FLAC/Ogg/Opus), TXXX:LYRICS (ID3), and MP4
|
||||
* freeform lyric atoms — metadata only, no PCM decode. ID3 USLT/SYLT arrive as
|
||||
* undecoded BinaryFrames and are out of scope. Returns { text } or null.
|
||||
* ExoPlayer decodes Vorbis/TXXX/MP4 text entries directly and exposes ID3 lyric
|
||||
* frames as BinaryFrame payloads, which Id3LyricsParser handles without reading
|
||||
* or decoding PCM.
|
||||
*/
|
||||
private fun readEmbeddedLyrics(uriStr: String): Map<String, Any?>? {
|
||||
private fun readEmbeddedLyrics(uriStr: String): Map<String, Any?> {
|
||||
return try {
|
||||
val mediaItem = MediaItem.fromUri(Uri.parse(uriStr))
|
||||
val trackGroups = MetadataRetriever.retrieveMetadata(requireContext(), mediaItem)
|
||||
.get(metadataTimeoutMs, TimeUnit.MILLISECONDS)
|
||||
|
||||
// Keep the longest lyric candidate (a full body beats a stray short tag).
|
||||
var best: String? = null
|
||||
val collector = EmbeddedLyricsCollector()
|
||||
fun consider(rawKey: String?, rawValue: String?) {
|
||||
if (rawKey == null || rawValue == null) return
|
||||
val key = rawKey.trim().lowercase()
|
||||
val isLyricKey = key in embeddedLyricKeys || key.contains("lyric") || key == "©lyr"
|
||||
if (!isLyricKey) return
|
||||
val value = rawValue.trim()
|
||||
if (value.isEmpty()) return
|
||||
if (best == null || value.length > best!!.length) best = value
|
||||
collector.considerPlain(rawValue)
|
||||
}
|
||||
|
||||
for (g in 0 until trackGroups.length) {
|
||||
@@ -369,17 +366,31 @@ class AstraLibraryScannerModule : Module() {
|
||||
val metadata = group.getFormat(f).metadata ?: continue
|
||||
for (i in 0 until metadata.length()) {
|
||||
when (val entry = metadata.get(i)) {
|
||||
is TextInformationFrame -> if (entry.id == "TXXX") consider(entry.description, entry.value)
|
||||
is TextInformationFrame -> when (entry.id) {
|
||||
"TXXX" -> consider(entry.description, entry.value)
|
||||
// ExoPlayer maps the standard MP4/M4A ©lyr atom to USLT text.
|
||||
"USLT", "ULT" -> collector.considerPlain(entry.value)
|
||||
else -> Unit
|
||||
}
|
||||
is VorbisComment -> consider(entry.key, entry.value)
|
||||
is InternalFrame -> consider(entry.description, entry.text)
|
||||
is BinaryFrame -> collector.consider(Id3LyricsParser.parse(entry.id, entry.data))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
best?.let { mapOf("text" to it) }
|
||||
val lyrics = collector.valueOrNull()
|
||||
?: return mapOf("status" to "missing")
|
||||
mapOf(
|
||||
"status" to "hit",
|
||||
"text" to lyrics.text,
|
||||
"syncText" to lyrics.syncText.map { entry ->
|
||||
mapOf("timestampMs" to entry.timestampMs, "text" to entry.text)
|
||||
},
|
||||
)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
mapOf("status" to "unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
package expo.modules.astralibraryscanner
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
internal data class SyncedLyricText(
|
||||
val timestampMs: Long,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
internal sealed interface Id3LyricsCandidate {
|
||||
data class Plain(val text: String) : Id3LyricsCandidate
|
||||
data class Synced(val entries: List<SyncedLyricText>) : Id3LyricsCandidate
|
||||
}
|
||||
|
||||
internal data class EmbeddedLyricsValue(
|
||||
val text: String?,
|
||||
val syncText: List<SyncedLyricText>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Collects lyric candidates from every metadata entry in a container. A valid
|
||||
* synchronized frame wins; otherwise the longest plain body wins. This keeps a
|
||||
* stray short tag from displacing a full lyric body while remaining deterministic.
|
||||
*/
|
||||
internal class EmbeddedLyricsCollector {
|
||||
private var bestPlain: String? = null
|
||||
private var bestSynced: List<SyncedLyricText>? = null
|
||||
|
||||
fun considerPlain(rawText: String?) {
|
||||
val text = normalizePlainText(rawText) ?: return
|
||||
if (bestPlain == null || text.length > bestPlain!!.length) bestPlain = text
|
||||
}
|
||||
|
||||
fun consider(candidate: Id3LyricsCandidate?) {
|
||||
when (candidate) {
|
||||
is Id3LyricsCandidate.Plain -> considerPlain(candidate.text)
|
||||
is Id3LyricsCandidate.Synced -> considerSynced(candidate.entries)
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun considerSynced(rawEntries: List<SyncedLyricText>) {
|
||||
val entries = rawEntries.filter { it.text.isNotBlank() }
|
||||
if (entries.isEmpty()) return
|
||||
val current = bestSynced
|
||||
if (
|
||||
current == null ||
|
||||
entries.size > current.size ||
|
||||
(entries.size == current.size && entries.sumOf { it.text.length } > current.sumOf { it.text.length })
|
||||
) {
|
||||
bestSynced = entries
|
||||
}
|
||||
}
|
||||
|
||||
fun valueOrNull(): EmbeddedLyricsValue? {
|
||||
val synced = bestSynced
|
||||
if (synced != null) {
|
||||
val text = normalizePlainText(synced.joinToString(separator = "") { it.text })
|
||||
return EmbeddedLyricsValue(text = text, syncText = synced)
|
||||
}
|
||||
return bestPlain?.let { EmbeddedLyricsValue(text = it, syncText = emptyList()) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Decoder for the payload bytes ExoPlayer exposes in ID3 BinaryFrame entries. */
|
||||
internal object Id3LyricsParser {
|
||||
private const val TIMESTAMP_FORMAT_MILLISECONDS = 2
|
||||
private val acceptedSyncedContentTypes = setOf(1, 2) // lyrics, text transcription
|
||||
|
||||
fun parse(frameId: String, data: ByteArray): Id3LyricsCandidate? = when (frameId.uppercase()) {
|
||||
"USLT", "ULT" -> parseUnsynchronized(data)
|
||||
"SYLT", "SLT" -> parseSynchronized(data)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun parseUnsynchronized(data: ByteArray): Id3LyricsCandidate.Plain? {
|
||||
if (data.size < 5) return null
|
||||
val encoding = data[0].toInt() and 0xff
|
||||
if (!isSupportedEncoding(encoding)) return null
|
||||
|
||||
// Encoding byte + ISO-639-2 language. The content descriptor is not part of
|
||||
// the displayed lyrics, but its BOM can establish UTF-16 byte order.
|
||||
val descriptor = readTerminated(data, 4, encoding) ?: return null
|
||||
val lyrics = decodeRange(data, descriptor.nextIndex, data.size, encoding, descriptor.utf16Charset)
|
||||
?: return null
|
||||
val normalized = normalizePlainText(lyrics) ?: return null
|
||||
return Id3LyricsCandidate.Plain(normalized)
|
||||
}
|
||||
|
||||
private fun parseSynchronized(data: ByteArray): Id3LyricsCandidate.Synced? {
|
||||
if (data.size < 7) return null
|
||||
val encoding = data[0].toInt() and 0xff
|
||||
if (!isSupportedEncoding(encoding)) return null
|
||||
val timestampFormat = data[4].toInt() and 0xff
|
||||
val contentType = data[5].toInt() and 0xff
|
||||
if (timestampFormat != TIMESTAMP_FORMAT_MILLISECONDS) return null
|
||||
if (contentType !in acceptedSyncedContentTypes) return null
|
||||
|
||||
val descriptor = readTerminated(data, 6, encoding) ?: return null
|
||||
var offset = descriptor.nextIndex
|
||||
val entries = mutableListOf<SyncedLyricText>()
|
||||
|
||||
while (offset < data.size) {
|
||||
val decoded = readTerminated(data, offset, encoding, descriptor.utf16Charset) ?: return null
|
||||
offset = decoded.nextIndex
|
||||
if (offset + 4 > data.size) return null
|
||||
|
||||
val timestamp =
|
||||
((data[offset].toLong() and 0xff) shl 24) or
|
||||
((data[offset + 1].toLong() and 0xff) shl 16) or
|
||||
((data[offset + 2].toLong() and 0xff) shl 8) or
|
||||
(data[offset + 3].toLong() and 0xff)
|
||||
offset += 4
|
||||
|
||||
val text = normalizeSyncedText(decoded.text)
|
||||
if (text.isNotBlank()) entries += SyncedLyricText(timestampMs = timestamp, text = text)
|
||||
}
|
||||
|
||||
return entries.takeIf { it.isNotEmpty() }?.let { Id3LyricsCandidate.Synced(it) }
|
||||
}
|
||||
|
||||
private data class DecodedTerminatedString(
|
||||
val text: String,
|
||||
val nextIndex: Int,
|
||||
val utf16Charset: Charset?,
|
||||
)
|
||||
|
||||
private fun readTerminated(
|
||||
data: ByteArray,
|
||||
start: Int,
|
||||
encoding: Int,
|
||||
inheritedUtf16Charset: Charset? = null,
|
||||
): DecodedTerminatedString? {
|
||||
if (start !in 0..data.size) return null
|
||||
val terminatorLength = if (encoding == 1 || encoding == 2) 2 else 1
|
||||
val end = findTerminator(data, start, terminatorLength) ?: return null
|
||||
val charset = resolveUtf16Charset(data, start, end, encoding, inheritedUtf16Charset)
|
||||
val text = decodeRange(data, start, end, encoding, charset) ?: return null
|
||||
return DecodedTerminatedString(text, end + terminatorLength, charset)
|
||||
}
|
||||
|
||||
private fun findTerminator(data: ByteArray, start: Int, terminatorLength: Int): Int? {
|
||||
if (terminatorLength == 1) {
|
||||
for (index in start until data.size) if (data[index].toInt() == 0) return index
|
||||
return null
|
||||
}
|
||||
|
||||
var index = start
|
||||
while (index + 1 < data.size) {
|
||||
if (data[index].toInt() == 0 && data[index + 1].toInt() == 0) return index
|
||||
index += 2
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun resolveUtf16Charset(
|
||||
data: ByteArray,
|
||||
start: Int,
|
||||
end: Int,
|
||||
encoding: Int,
|
||||
inherited: Charset?,
|
||||
): Charset? {
|
||||
if (encoding == 2) return StandardCharsets.UTF_16BE
|
||||
if (encoding != 1) return null
|
||||
if (end - start >= 2) {
|
||||
val first = data[start].toInt() and 0xff
|
||||
val second = data[start + 1].toInt() and 0xff
|
||||
if (first == 0xfe && second == 0xff) return StandardCharsets.UTF_16BE
|
||||
if (first == 0xff && second == 0xfe) return StandardCharsets.UTF_16LE
|
||||
}
|
||||
return inherited ?: StandardCharsets.UTF_16BE
|
||||
}
|
||||
|
||||
private fun decodeRange(
|
||||
data: ByteArray,
|
||||
start: Int,
|
||||
end: Int,
|
||||
encoding: Int,
|
||||
utf16Charset: Charset? = null,
|
||||
): String? {
|
||||
if (start < 0 || end < start || end > data.size) return null
|
||||
val charset = when (encoding) {
|
||||
0 -> StandardCharsets.ISO_8859_1
|
||||
1 -> utf16Charset ?: resolveUtf16Charset(data, start, end, encoding, null) ?: return null
|
||||
2 -> StandardCharsets.UTF_16BE
|
||||
3 -> StandardCharsets.UTF_8
|
||||
else -> return null
|
||||
}
|
||||
var contentStart = start
|
||||
if (encoding == 1 && end - start >= 2) {
|
||||
val first = data[start].toInt() and 0xff
|
||||
val second = data[start + 1].toInt() and 0xff
|
||||
if ((first == 0xfe && second == 0xff) || (first == 0xff && second == 0xfe)) {
|
||||
contentStart += 2
|
||||
}
|
||||
}
|
||||
val contentLength = end - contentStart
|
||||
if ((encoding == 1 || encoding == 2) && contentLength % 2 != 0) return null
|
||||
return try {
|
||||
charset.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(data, contentStart, contentLength))
|
||||
.toString()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSupportedEncoding(encoding: Int): Boolean = encoding in 0..3
|
||||
}
|
||||
|
||||
private fun normalizePlainText(value: String?): String? {
|
||||
if (value == null) return null
|
||||
val normalized = value.replace("\r\n", "\n").replace('\r', '\n').trim().trim('\u0000')
|
||||
return normalized.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun normalizeSyncedText(value: String): String =
|
||||
value.replace("\r\n", "\n").replace('\r', '\n').trim('\u0000')
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package expo.modules.astralibraryscanner
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class Id3LyricsParserTest {
|
||||
@Test
|
||||
fun `USLT decodes every ID3 text encoding`() {
|
||||
val samples = listOf(
|
||||
0 to "Café line",
|
||||
1 to "日本語の歌詞",
|
||||
2 to "Ελληνικοί στίχοι",
|
||||
3 to "한글 가사",
|
||||
)
|
||||
|
||||
for ((encoding, expected) in samples) {
|
||||
val candidate = Id3LyricsParser.parse("USLT", uslt(encoding, expected))
|
||||
assertEquals(expected, (candidate as Id3LyricsCandidate.Plain).text)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ULT alias decodes multiline plain lyrics`() {
|
||||
val candidate = Id3LyricsParser.parse("ULT", uslt(3, "First\r\nSecond"))
|
||||
|
||||
assertEquals("First\nSecond", (candidate as Id3LyricsCandidate.Plain).text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SYLT and SLT decode millisecond synchronized entries`() {
|
||||
for (frameId in listOf("SYLT", "SLT")) {
|
||||
val candidate = Id3LyricsParser.parse(
|
||||
frameId,
|
||||
sylt(
|
||||
encoding = 3,
|
||||
timestampFormat = 2,
|
||||
contentType = 1,
|
||||
entries = listOf(1_000L to "First", 2_500L to " Second"),
|
||||
)
|
||||
) as Id3LyricsCandidate.Synced
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
SyncedLyricText(1_000, "First"),
|
||||
SyncedLyricText(2_500, " Second"),
|
||||
),
|
||||
candidate.entries,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SYLT accepts text transcription but rejects unrelated content`() {
|
||||
assertTrue(
|
||||
Id3LyricsParser.parse(
|
||||
"SYLT",
|
||||
sylt(0, 2, 2, listOf(500L to "Transcript")),
|
||||
) is Id3LyricsCandidate.Synced
|
||||
)
|
||||
assertNull(
|
||||
Id3LyricsParser.parse(
|
||||
"SYLT",
|
||||
sylt(0, 2, 3, listOf(500L to "Movement")),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SYLT rejects MPEG-frame timestamps`() {
|
||||
assertNull(
|
||||
Id3LyricsParser.parse(
|
||||
"SYLT",
|
||||
sylt(3, 1, 1, listOf(42L to "Unsupported units")),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed and unsupported payloads fail closed`() {
|
||||
assertNull(Id3LyricsParser.parse("USLT", byteArrayOf(3, 'e'.code.toByte())))
|
||||
assertNull(Id3LyricsParser.parse("USLT", byteArrayOf(9, 'e'.code.toByte(), 'n'.code.toByte(), 'g'.code.toByte(), 0)))
|
||||
assertNull(Id3LyricsParser.parse("USLT", byteArrayOf(3, 'e'.code.toByte(), 'n'.code.toByte(), 'g'.code.toByte(), 0, 0xc3.toByte(), 0x28)))
|
||||
assertNull(Id3LyricsParser.parse("USLT", byteArrayOf(2, 'e'.code.toByte(), 'n'.code.toByte(), 'g'.code.toByte(), 0, 0, 0x41)))
|
||||
assertNull(Id3LyricsParser.parse("SYLT", sylt(3, 2, 1, listOf(1_000L to "Line")).dropLast(2).toByteArray()))
|
||||
assertNull(Id3LyricsParser.parse("COMM", uslt(3, "Not lyrics")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collector prefers the most complete synchronized candidate`() {
|
||||
val collector = EmbeddedLyricsCollector()
|
||||
collector.considerPlain("A much longer plain lyric body that should not win")
|
||||
collector.consider(
|
||||
Id3LyricsCandidate.Synced(listOf(SyncedLyricText(1_000, "One")))
|
||||
)
|
||||
collector.consider(
|
||||
Id3LyricsCandidate.Synced(
|
||||
listOf(
|
||||
SyncedLyricText(1_000, "First "),
|
||||
SyncedLyricText(2_000, "second"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = collector.valueOrNull()!!
|
||||
assertEquals("First second", result.text)
|
||||
assertEquals(2, result.syncText.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `collector chooses the longest plain candidate when no sync exists`() {
|
||||
val collector = EmbeddedLyricsCollector()
|
||||
collector.considerPlain("Short")
|
||||
collector.considerPlain("The complete lyric body")
|
||||
|
||||
val result = collector.valueOrNull()!!
|
||||
assertEquals("The complete lyric body", result.text)
|
||||
assertTrue(result.syncText.isEmpty())
|
||||
}
|
||||
|
||||
private fun uslt(encoding: Int, text: String): ByteArray = ByteArrayOutputStream().use { output ->
|
||||
output.write(encoding)
|
||||
output.write("eng".toByteArray(StandardCharsets.ISO_8859_1))
|
||||
output.write(terminated(encoding, "description"))
|
||||
output.write(encoded(encoding, text))
|
||||
output.toByteArray()
|
||||
}
|
||||
|
||||
private fun sylt(
|
||||
encoding: Int,
|
||||
timestampFormat: Int,
|
||||
contentType: Int,
|
||||
entries: List<Pair<Long, String>>,
|
||||
): ByteArray = ByteArrayOutputStream().use { output ->
|
||||
output.write(encoding)
|
||||
output.write("eng".toByteArray(StandardCharsets.ISO_8859_1))
|
||||
output.write(timestampFormat)
|
||||
output.write(contentType)
|
||||
output.write(terminated(encoding, ""))
|
||||
for ((timestamp, text) in entries) {
|
||||
output.write(terminated(encoding, text))
|
||||
output.write(((timestamp ushr 24) and 0xff).toInt())
|
||||
output.write(((timestamp ushr 16) and 0xff).toInt())
|
||||
output.write(((timestamp ushr 8) and 0xff).toInt())
|
||||
output.write((timestamp and 0xff).toInt())
|
||||
}
|
||||
output.toByteArray()
|
||||
}
|
||||
|
||||
private fun terminated(encoding: Int, text: String): ByteArray =
|
||||
encoded(encoding, text) + if (encoding == 1 || encoding == 2) byteArrayOf(0, 0) else byteArrayOf(0)
|
||||
|
||||
private fun encoded(encoding: Int, text: String): ByteArray = when (encoding) {
|
||||
0 -> text.toByteArray(StandardCharsets.ISO_8859_1)
|
||||
1 -> byteArrayOf(0xff.toByte(), 0xfe.toByte()) + text.toByteArray(StandardCharsets.UTF_16LE)
|
||||
2 -> text.toByteArray(StandardCharsets.UTF_16BE)
|
||||
3 -> text.toByteArray(StandardCharsets.UTF_8)
|
||||
else -> text.toByteArray(StandardCharsets.UTF_8)
|
||||
}
|
||||
}
|
||||
@@ -57,11 +57,21 @@ export interface SidecarLyrics {
|
||||
format: 'xlrc' | 'lrc';
|
||||
}
|
||||
|
||||
/** Embedded lyrics text read from container tags. */
|
||||
export interface EmbeddedLyrics {
|
||||
export interface EmbeddedLyricsSyncText {
|
||||
timestampMs: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Embedded lyrics read from container tags without decoding audio. */
|
||||
export type EmbeddedLyricsReadResult =
|
||||
| {
|
||||
status: 'hit';
|
||||
text: string | null;
|
||||
syncText: EmbeddedLyricsSyncText[];
|
||||
}
|
||||
| { status: 'missing' }
|
||||
| { status: 'unavailable' };
|
||||
|
||||
export interface ScanProgressEvent {
|
||||
phase: 'discovering';
|
||||
found: number;
|
||||
@@ -103,11 +113,11 @@ declare class AstraLibraryScannerModuleType extends NativeModule<AstraLibrarySca
|
||||
*/
|
||||
readSidecarLyrics(uri: string): Promise<SidecarLyrics | null>;
|
||||
/**
|
||||
* Read embedded lyrics from container tags (Vorbis LYRICS/UNSYNCEDLYRICS for
|
||||
* FLAC/Ogg/Opus, TXXX:LYRICS, MP4 lyric atoms) without decoding audio. Null when
|
||||
* absent. ID3 USLT/SYLT are not covered (ExoPlayer leaves them undecoded).
|
||||
* Read embedded lyrics from Vorbis, ID3 (USLT/SYLT/TXXX), and MP4/M4A tags
|
||||
* without decoding audio. `missing` means metadata was read successfully but
|
||||
* no supported tag was present; `unavailable` preserves cache on I/O failure.
|
||||
*/
|
||||
readEmbeddedLyrics(uri: string): Promise<EmbeddedLyrics | null>;
|
||||
readEmbeddedLyrics(uri: string): Promise<EmbeddedLyricsReadResult>;
|
||||
getArtworkDirPath(): string;
|
||||
getArtworkThumbDirPath(): string;
|
||||
ensureArtworkThumbnails(hashes: string[]): Promise<number>;
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
"test:signal": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/signalShare.test.mts src/audio/signalShareIntent.test.mts src/audio/signalScanGeometry.test.mts src/audio/signalLocalMatch.test.mts",
|
||||
"test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts",
|
||||
"test:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts",
|
||||
"test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts",
|
||||
"test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts src/lyrics/embedded.test.mts src/lyrics/resolver.test.mts",
|
||||
"test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts",
|
||||
"test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/db/libraryMaintenance.test.mts src/lib/cacheInvalidation.test.mts",
|
||||
"test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts",
|
||||
|
||||
@@ -111,6 +111,10 @@ export async function getLyricsCacheCount(db: LibraryDatabase): Promise<number>
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function deleteLyricsCache(db: LibraryDatabase, trackPath: string): Promise<void> {
|
||||
await db.run('DELETE FROM lyrics_cache WHERE track_path = ?', [trackPath]);
|
||||
}
|
||||
|
||||
export async function clearLyricsCache(db: LibraryDatabase): Promise<void> {
|
||||
await db.run('DELETE FROM lyrics_cache');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createEmbeddedLyricsPayload, isLocalLyricsPath } from './embedded.ts';
|
||||
import { getLyricsPayloadSourceLabel } from './presentation.ts';
|
||||
|
||||
test('plain embedded text becomes an Embedded plain payload', () => {
|
||||
const payload = createEmbeddedLyricsPayload({
|
||||
status: 'hit',
|
||||
text: 'First line\nSecond line',
|
||||
syncText: [],
|
||||
});
|
||||
|
||||
assert.equal(payload?.source, 'embedded');
|
||||
assert.equal(payload?.format, 'plain');
|
||||
assert.equal(payload?.plainLyrics, 'First line\nSecond line');
|
||||
assert.equal(getLyricsPayloadSourceLabel(payload!), 'Embedded');
|
||||
});
|
||||
|
||||
test('timestamped text embedded in a plain tag is parsed as LRC', () => {
|
||||
const payload = createEmbeddedLyricsPayload({
|
||||
status: 'hit',
|
||||
text: '[00:01.00]First\n[00:02.50]Second',
|
||||
syncText: [],
|
||||
});
|
||||
|
||||
assert.equal(payload?.format, 'lrc');
|
||||
assert.deepEqual(payload?.syncedLines.map((line) => [line.timestampMs, line.text]), [
|
||||
[1_000, 'First'],
|
||||
[2_500, 'Second'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('structured SYLT entries take precedence over timestamps in raw text', () => {
|
||||
const payload = createEmbeddedLyricsPayload({
|
||||
status: 'hit',
|
||||
text: '[00:09.00]Raw fallback',
|
||||
syncText: [
|
||||
{ timestampMs: 2_000, text: 'Second' },
|
||||
{ timestampMs: 1_000, text: 'First' },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(payload?.format, 'lrc');
|
||||
assert.deepEqual(payload?.syncedLines.map((line) => [line.timestampMs, line.text]), [
|
||||
[1_000, 'First'],
|
||||
[2_000, 'Second'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('only local file schemes are eligible for native embedded inspection', () => {
|
||||
assert.equal(isLocalLyricsPath('content://media/track/1'), true);
|
||||
assert.equal(isLocalLyricsPath('file:///music/track.mp3'), true);
|
||||
assert.equal(isLocalLyricsPath('subsonic://server/track/1'), false);
|
||||
assert.equal(isLocalLyricsPath('jellyfin://server/track/1'), false);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { EmbeddedLyricsReadResult } from '../../modules/astra-library-scanner';
|
||||
import {
|
||||
createLyricsPayload,
|
||||
parseLyricsText,
|
||||
sanitizeLyricsLines,
|
||||
toPlainLyricsFromLines,
|
||||
} from './parsing.ts';
|
||||
import type { LyricsPayload } from './types';
|
||||
|
||||
export type EmbeddedLyricsResolution =
|
||||
| { status: 'hit'; lyrics: LyricsPayload }
|
||||
| { status: 'missing' | 'unavailable' | 'not_local' };
|
||||
|
||||
/** Pure bridge adapter kept separate from the native module for Node tests. */
|
||||
export function createEmbeddedLyricsPayload(
|
||||
result: Extract<EmbeddedLyricsReadResult, { status: 'hit' }>
|
||||
): LyricsPayload | null {
|
||||
const parsedText = result.text
|
||||
? parseLyricsText(result.text, 'embedded', 'lrc')
|
||||
: null;
|
||||
const syncedLines = sanitizeLyricsLines(result.syncText.map((entry) => ({
|
||||
timestampMs: entry.timestampMs,
|
||||
text: entry.text,
|
||||
})));
|
||||
|
||||
if (syncedLines.length === 0) return parsedText;
|
||||
|
||||
return createLyricsPayload(
|
||||
'embedded',
|
||||
null,
|
||||
'lrc',
|
||||
parsedText?.plainLyrics ?? toPlainLyricsFromLines(syncedLines),
|
||||
null,
|
||||
syncedLines
|
||||
);
|
||||
}
|
||||
|
||||
export function isLocalLyricsPath(path: string): boolean {
|
||||
return path.startsWith('content://') || path.startsWith('file://');
|
||||
}
|
||||
+13
-12
@@ -4,16 +4,17 @@
|
||||
// over online lookup (see the orchestrator ordering in lyrics.ts).
|
||||
|
||||
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
|
||||
import {
|
||||
createEmbeddedLyricsPayload,
|
||||
isLocalLyricsPath,
|
||||
type EmbeddedLyricsResolution,
|
||||
} from './embedded';
|
||||
import { parseLyricsText } from './parsing';
|
||||
import type { LyricsPayload } from './types';
|
||||
|
||||
function isLocalPath(path: string): boolean {
|
||||
return path.startsWith('content://') || path.startsWith('file://');
|
||||
}
|
||||
|
||||
/** Resolve a sibling `<name>.xlrc`/`.lrc` next to a local track. */
|
||||
export async function resolveSidecarLyrics(trackPath: string): Promise<LyricsPayload | null> {
|
||||
if (!isLocalPath(trackPath)) return null;
|
||||
if (!isLocalLyricsPath(trackPath)) return null;
|
||||
try {
|
||||
const sidecar = await AstraLibraryScanner.readSidecarLyrics(trackPath);
|
||||
if (!sidecar?.text) return null;
|
||||
@@ -24,15 +25,15 @@ export async function resolveSidecarLyrics(trackPath: string): Promise<LyricsPay
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve embedded lyrics (Vorbis LYRICS/etc.) from a local track's container. */
|
||||
export async function resolveEmbeddedLyrics(trackPath: string): Promise<LyricsPayload | null> {
|
||||
if (!isLocalPath(trackPath)) return null;
|
||||
/** Resolve embedded lyrics from a local track while preserving miss vs I/O failure. */
|
||||
export async function resolveEmbeddedLyrics(trackPath: string): Promise<EmbeddedLyricsResolution> {
|
||||
if (!isLocalLyricsPath(trackPath)) return { status: 'not_local' };
|
||||
try {
|
||||
const embedded = await AstraLibraryScanner.readEmbeddedLyrics(trackPath);
|
||||
if (!embedded?.text) return null;
|
||||
// Parse as LRC so timestamped tags become synced; plain text stays plain.
|
||||
return parseLyricsText(embedded.text, 'embedded', 'lrc');
|
||||
if (embedded.status !== 'hit') return embedded;
|
||||
const lyrics = createEmbeddedLyricsPayload(embedded);
|
||||
return lyrics ? { status: 'hit', lyrics } : { status: 'missing' };
|
||||
} catch {
|
||||
return null;
|
||||
return { status: 'unavailable' };
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,168 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { LyricsCacheEntry } from '../db/lyricsQueries.ts';
|
||||
import type { EmbeddedLyricsResolution } from './embedded.ts';
|
||||
import {
|
||||
resolveLyricsWithDependencies,
|
||||
type LyricsProviderLookupResult,
|
||||
type LyricsResolverDependencies,
|
||||
} from './resolver.ts';
|
||||
import type { LyricsPayload, LyricsSource, LyricsTrackQuery } from './types.ts';
|
||||
|
||||
const QUERY: LyricsTrackQuery = {
|
||||
path: 'content://music/track.mp3',
|
||||
title: 'Track',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
durationSeconds: 180,
|
||||
};
|
||||
|
||||
function payload(source: LyricsSource, text: string): LyricsPayload {
|
||||
return {
|
||||
source,
|
||||
provider: source === 'xlrcdb' || source === 'lrclib' ? source : null,
|
||||
format: 'plain',
|
||||
plainLyrics: text,
|
||||
syncedLyrics: null,
|
||||
syncedLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
function cache(source: LyricsSource, text: string): LyricsCacheEntry {
|
||||
return {
|
||||
status: 'hit',
|
||||
source,
|
||||
provider: source === 'xlrcdb' || source === 'lrclib' ? source : null,
|
||||
format: 'plain',
|
||||
plainLyrics: text,
|
||||
syncedLyrics: null,
|
||||
syncedLines: [],
|
||||
};
|
||||
}
|
||||
|
||||
function dependencies(overrides: Partial<LyricsResolverDependencies> = {}) {
|
||||
const calls = {
|
||||
sidecar: 0,
|
||||
embedded: 0,
|
||||
cache: 0,
|
||||
deleteCache: 0,
|
||||
cachedHits: [] as LyricsPayload[],
|
||||
notFound: 0,
|
||||
xlrcdb: 0,
|
||||
lrclib: 0,
|
||||
};
|
||||
const deps: LyricsResolverDependencies = {
|
||||
resolveSidecar: async () => {
|
||||
calls.sidecar += 1;
|
||||
return null;
|
||||
},
|
||||
resolveEmbedded: async (): Promise<EmbeddedLyricsResolution> => {
|
||||
calls.embedded += 1;
|
||||
return { status: 'missing' };
|
||||
},
|
||||
getCache: async () => {
|
||||
calls.cache += 1;
|
||||
return null;
|
||||
},
|
||||
deleteCache: async () => {
|
||||
calls.deleteCache += 1;
|
||||
},
|
||||
cacheHit: async (lyrics) => {
|
||||
calls.cachedHits.push(lyrics);
|
||||
},
|
||||
cacheNotFound: async () => {
|
||||
calls.notFound += 1;
|
||||
},
|
||||
lookupXlrcdb: async (): Promise<LyricsProviderLookupResult> => {
|
||||
calls.xlrcdb += 1;
|
||||
return { status: 'not_found' };
|
||||
},
|
||||
lookupLrclib: async (): Promise<LyricsProviderLookupResult> => {
|
||||
calls.lrclib += 1;
|
||||
return { status: 'not_found' };
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
return { calls, deps };
|
||||
}
|
||||
|
||||
test('sidecar remains the highest-priority source', async () => {
|
||||
const sidecar = payload('lrc', 'Sidecar');
|
||||
const { calls, deps } = dependencies({ resolveSidecar: async () => sidecar });
|
||||
|
||||
const result = await resolveLyricsWithDependencies(
|
||||
QUERY,
|
||||
{ forceRefresh: false, onlineEnabled: true },
|
||||
deps
|
||||
);
|
||||
|
||||
assert.equal(result.status === 'hit' ? result.lyrics.source : '', 'lrc');
|
||||
assert.equal(calls.embedded, 0);
|
||||
assert.equal(calls.cache, 0);
|
||||
});
|
||||
|
||||
test('fresh embedded lyrics beat a cached online result and replace it', async () => {
|
||||
const embedded = payload('embedded', 'Local');
|
||||
const { calls, deps } = dependencies({
|
||||
resolveEmbedded: async () => ({ status: 'hit', lyrics: embedded }),
|
||||
getCache: async () => cache('xlrcdb', 'Online'),
|
||||
});
|
||||
|
||||
const result = await resolveLyricsWithDependencies(
|
||||
QUERY,
|
||||
{ forceRefresh: false, onlineEnabled: true },
|
||||
deps
|
||||
);
|
||||
|
||||
assert.equal(result.status === 'hit' ? result.lyrics.source : '', 'embedded');
|
||||
assert.equal(calls.cache, 0);
|
||||
assert.deepEqual(calls.cachedHits, [embedded]);
|
||||
assert.equal(calls.xlrcdb, 0);
|
||||
});
|
||||
|
||||
test('a confirmed embedded miss removes a stale embedded cache row', async () => {
|
||||
const { calls, deps } = dependencies({
|
||||
getCache: async () => cache('embedded', 'Removed'),
|
||||
});
|
||||
|
||||
const result = await resolveLyricsWithDependencies(
|
||||
QUERY,
|
||||
{ forceRefresh: false, onlineEnabled: false },
|
||||
deps
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { status: 'not_found', reason: 'online-disabled' });
|
||||
assert.equal(calls.deleteCache, 1);
|
||||
});
|
||||
|
||||
test('an unavailable metadata reader preserves and returns cached lyrics', async () => {
|
||||
const { calls, deps } = dependencies({
|
||||
resolveEmbedded: async () => ({ status: 'unavailable' }),
|
||||
getCache: async () => cache('embedded', 'Cached local'),
|
||||
});
|
||||
|
||||
const result = await resolveLyricsWithDependencies(
|
||||
QUERY,
|
||||
{ forceRefresh: false, onlineEnabled: false },
|
||||
deps
|
||||
);
|
||||
|
||||
assert.equal(result.status === 'hit' ? result.lyrics.plainLyrics : '', 'Cached local');
|
||||
assert.equal(result.status === 'hit' ? result.cached : false, true);
|
||||
assert.equal(calls.deleteCache, 0);
|
||||
});
|
||||
|
||||
test('local misses fall through to XLRCDB then LRCLIB and cache definitive misses', async () => {
|
||||
const { calls, deps } = dependencies();
|
||||
|
||||
const result = await resolveLyricsWithDependencies(
|
||||
QUERY,
|
||||
{ forceRefresh: false, onlineEnabled: true },
|
||||
deps
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { status: 'not_found', reason: 'provider-not-found' });
|
||||
assert.equal(calls.xlrcdb, 1);
|
||||
assert.equal(calls.lrclib, 1);
|
||||
assert.equal(calls.notFound, 1);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { LyricsCacheEntry } from '../db/lyricsQueries';
|
||||
import type { EmbeddedLyricsResolution } from './embedded';
|
||||
import { createLyricsPayload } from './parsing.ts';
|
||||
import type { LyricsLookupResult, LyricsPayload, LyricsTrackQuery } from './types';
|
||||
|
||||
export type LyricsProviderLookupResult =
|
||||
| { status: 'hit'; lyrics: LyricsPayload }
|
||||
| { status: 'not_found' }
|
||||
| { status: 'skipped'; reason: string }
|
||||
| { status: 'provider_unavailable' }
|
||||
| { status: 'transient_error'; message: string; code?: string };
|
||||
|
||||
export interface LyricsResolverDependencies {
|
||||
resolveSidecar: (trackPath: string) => Promise<LyricsPayload | null>;
|
||||
resolveEmbedded: (trackPath: string) => Promise<EmbeddedLyricsResolution>;
|
||||
getCache: () => Promise<LyricsCacheEntry | null>;
|
||||
deleteCache: () => Promise<void>;
|
||||
cacheHit: (payload: LyricsPayload) => Promise<void>;
|
||||
cacheNotFound: () => Promise<void>;
|
||||
lookupXlrcdb: (query: LyricsTrackQuery, forceRefresh: boolean) => Promise<LyricsProviderLookupResult>;
|
||||
lookupLrclib: (query: LyricsTrackQuery, forceRefresh: boolean) => Promise<LyricsProviderLookupResult>;
|
||||
}
|
||||
|
||||
export interface LyricsResolverOptions {
|
||||
forceRefresh: boolean;
|
||||
onlineEnabled: boolean;
|
||||
}
|
||||
|
||||
export function resultFromLyricsCache(cache: LyricsCacheEntry): LyricsLookupResult | null {
|
||||
if (cache.status === 'hit') {
|
||||
const payload = createLyricsPayload(
|
||||
cache.source,
|
||||
cache.provider,
|
||||
cache.format,
|
||||
cache.plainLyrics,
|
||||
cache.syncedLyrics,
|
||||
cache.syncedLines
|
||||
);
|
||||
if (!payload) return null;
|
||||
return { status: 'hit', lyrics: payload, cached: true };
|
||||
}
|
||||
return {
|
||||
status: 'not_found',
|
||||
reason: cache.source === 'embedded' ? 'embedded-missing' : 'provider-not-found',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-order policy isolated from Expo/native/database imports so it can be
|
||||
* exercised directly under Node. Local sources always run before persisted or
|
||||
* online results.
|
||||
*/
|
||||
export async function resolveLyricsWithDependencies(
|
||||
query: LyricsTrackQuery,
|
||||
options: LyricsResolverOptions,
|
||||
dependencies: LyricsResolverDependencies
|
||||
): Promise<LyricsLookupResult> {
|
||||
const { forceRefresh, onlineEnabled } = options;
|
||||
|
||||
const sidecar = await dependencies.resolveSidecar(query.path);
|
||||
if (sidecar) return { status: 'hit', lyrics: sidecar, cached: false };
|
||||
|
||||
const embedded = await dependencies.resolveEmbedded(query.path);
|
||||
if (embedded.status === 'hit') {
|
||||
await dependencies.cacheHit(embedded.lyrics);
|
||||
return { status: 'hit', lyrics: embedded.lyrics, cached: false };
|
||||
}
|
||||
|
||||
let cached = await dependencies.getCache();
|
||||
if (embedded.status === 'missing' && cached?.source === 'embedded') {
|
||||
await dependencies.deleteCache();
|
||||
cached = null;
|
||||
}
|
||||
|
||||
let lrclibCached: LyricsCacheEntry | null = null;
|
||||
if ((!forceRefresh || !onlineEnabled) && cached) {
|
||||
// Preserve the existing migration behavior: an older LRCLIB cache hit waits
|
||||
// until XLRCDB has had one chance to provide the preferred result.
|
||||
if (cached.status === 'hit' && cached.source === 'lrclib' && onlineEnabled) {
|
||||
lrclibCached = cached;
|
||||
} else {
|
||||
const cachedResult = resultFromLyricsCache(cached);
|
||||
if (cachedResult) return cachedResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (!onlineEnabled) return { status: 'not_found', reason: 'online-disabled' };
|
||||
|
||||
const xlrcdb = await dependencies.lookupXlrcdb(query, forceRefresh);
|
||||
if (xlrcdb.status === 'hit') {
|
||||
await dependencies.cacheHit(xlrcdb.lyrics);
|
||||
return { status: 'hit', lyrics: xlrcdb.lyrics, cached: false };
|
||||
}
|
||||
|
||||
if (lrclibCached) {
|
||||
const cachedResult = resultFromLyricsCache(lrclibCached);
|
||||
if (cachedResult) return cachedResult;
|
||||
}
|
||||
|
||||
const lrclib = await dependencies.lookupLrclib(query, forceRefresh);
|
||||
if (lrclib.status === 'hit') {
|
||||
await dependencies.cacheHit(lrclib.lyrics);
|
||||
return { status: 'hit', lyrics: lrclib.lyrics, cached: false };
|
||||
}
|
||||
if (lrclib.status === 'provider_unavailable') {
|
||||
return { status: 'not_found', reason: 'provider-unavailable' };
|
||||
}
|
||||
if (lrclib.status === 'transient_error') {
|
||||
return { status: 'transient_error', message: lrclib.message, code: lrclib.code };
|
||||
}
|
||||
|
||||
if (xlrcdb.status === 'not_found') await dependencies.cacheNotFound();
|
||||
return { status: 'not_found', reason: 'provider-not-found' };
|
||||
}
|
||||
Reference in New Issue
Block a user