diff --git a/modules/astra-library-scanner/android/build.gradle b/modules/astra-library-scanner/android/build.gradle index 165986b..6326f60 100644 --- a/modules/astra-library-scanner/android/build.gradle +++ b/modules/astra-library-scanner/android/build.gradle @@ -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' } diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt index 86eaf33..8f3d9d7 100644 --- a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/AstraLibraryScannerModule.kt @@ -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? { + private fun readEmbeddedLyrics(uriStr: String): Map { 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") } } diff --git a/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/Id3LyricsParser.kt b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/Id3LyricsParser.kt new file mode 100644 index 0000000..006a943 --- /dev/null +++ b/modules/astra-library-scanner/android/src/main/java/expo/modules/astralibraryscanner/Id3LyricsParser.kt @@ -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) : Id3LyricsCandidate +} + +internal data class EmbeddedLyricsValue( + val text: String?, + val syncText: List, +) + +/** + * 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? = 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) { + 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() + + 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') diff --git a/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/Id3LyricsParserTest.kt b/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/Id3LyricsParserTest.kt new file mode 100644 index 0000000..9892df1 --- /dev/null +++ b/modules/astra-library-scanner/android/src/test/java/expo/modules/astralibraryscanner/Id3LyricsParserTest.kt @@ -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>, + ): 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) + } +} diff --git a/modules/astra-library-scanner/index.ts b/modules/astra-library-scanner/index.ts index 1f9a764..378fefa 100644 --- a/modules/astra-library-scanner/index.ts +++ b/modules/astra-library-scanner/index.ts @@ -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; /** - * 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; + readEmbeddedLyrics(uri: string): Promise; getArtworkDirPath(): string; getArtworkThumbDirPath(): string; ensureArtworkThumbnails(hashes: string[]): Promise; diff --git a/package.json b/package.json index 2c39b41..4e9c3c0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/db/lyricsQueries.ts b/src/db/lyricsQueries.ts index 56622c4..91b3dc2 100644 --- a/src/db/lyricsQueries.ts +++ b/src/db/lyricsQueries.ts @@ -111,6 +111,10 @@ export async function getLyricsCacheCount(db: LibraryDatabase): Promise return row?.count ?? 0; } +export async function deleteLyricsCache(db: LibraryDatabase, trackPath: string): Promise { + await db.run('DELETE FROM lyrics_cache WHERE track_path = ?', [trackPath]); +} + export async function clearLyricsCache(db: LibraryDatabase): Promise { await db.run('DELETE FROM lyrics_cache'); } diff --git a/src/lyrics/embedded.test.mts b/src/lyrics/embedded.test.mts new file mode 100644 index 0000000..f4af6cb --- /dev/null +++ b/src/lyrics/embedded.test.mts @@ -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); +}); diff --git a/src/lyrics/embedded.ts b/src/lyrics/embedded.ts new file mode 100644 index 0000000..90d75cb --- /dev/null +++ b/src/lyrics/embedded.ts @@ -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 +): 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://'); +} diff --git a/src/lyrics/local.ts b/src/lyrics/local.ts index 2ca3fdf..be6d7e0 100644 --- a/src/lyrics/local.ts +++ b/src/lyrics/local.ts @@ -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 `.xlrc`/`.lrc` next to a local track. */ export async function resolveSidecarLyrics(trackPath: string): Promise { - 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 { - 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 { + 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' }; } } diff --git a/src/lyrics/lyrics.ts b/src/lyrics/lyrics.ts index 462e2ba..cea1f86 100644 Binary files a/src/lyrics/lyrics.ts and b/src/lyrics/lyrics.ts differ diff --git a/src/lyrics/resolver.test.mts b/src/lyrics/resolver.test.mts new file mode 100644 index 0000000..4fe904f --- /dev/null +++ b/src/lyrics/resolver.test.mts @@ -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 = {}) { + 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 => { + 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 => { + calls.xlrcdb += 1; + return { status: 'not_found' }; + }, + lookupLrclib: async (): Promise => { + 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); +}); diff --git a/src/lyrics/resolver.ts b/src/lyrics/resolver.ts new file mode 100644 index 0000000..75bf190 --- /dev/null +++ b/src/lyrics/resolver.ts @@ -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; + resolveEmbedded: (trackPath: string) => Promise; + getCache: () => Promise; + deleteCache: () => Promise; + cacheHit: (payload: LyricsPayload) => Promise; + cacheNotFound: () => Promise; + lookupXlrcdb: (query: LyricsTrackQuery, forceRefresh: boolean) => Promise; + lookupLrclib: (query: LyricsTrackQuery, forceRefresh: boolean) => Promise; +} + +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 { + 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' }; +}