From dba5f044e5d5d2d50aecb4593560129998c1235d Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:43:28 -0400 Subject: [PATCH 1/7] refactor: Have `LyricsParser` class handle everything for else - It acts more like a function rather than a class. - If we want things to be reusable, instead of accepting some initial parameters, we would expose a `parseFrom` function which might be overloaded to support `List` & `Metadata`. --- .../{utils => modules}/LyricsParser.kt | 56 ++++++++++++------- .../modules/MetadataRetrieverModule.kt | 19 +------ 2 files changed, 38 insertions(+), 37 deletions(-) rename android/src/main/java/com/cyanchill/missingcore/metadataretriever/{utils => modules}/LyricsParser.kt (61%) diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt similarity index 61% rename from android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt rename to android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt index 69a7f75..9a3b992 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt @@ -1,4 +1,4 @@ -package com.cyanchill.missingcore.metadataretriever.utils +package com.cyanchill.missingcore.metadataretriever.modules import androidx.annotation.OptIn import androidx.media3.common.Metadata @@ -7,33 +7,52 @@ import androidx.media3.extractor.metadata.id3.BinaryFrame import androidx.media3.extractor.metadata.id3.TextInformationFrame import androidx.media3.extractor.metadata.vorbis.VorbisComment +private data class ParsedResult( + val lyrics: String?, + val isSync: Boolean, +) + @OptIn(UnstableApi::class) -class LyricsParser(entry: Metadata.Entry) { +class LyricsParser(metadataList: List) { + private var isSync = false var lyrics: String? = null - var isSync = false init { - // Extra lyrics based on the class. - when (entry) { + for (metadata in metadataList) { + val numEntries = metadata.length() + for (i in 0 until numEntries) { + parseMetadataEntry(metadata[i]) + if (isSync) break + } + if (isSync) break + } + } + + private fun parseMetadataEntry(entry: Metadata.Entry) { + val result = when (entry) { is TextInformationFrame -> handleTextInformationFrame(entry) is BinaryFrame -> handleBinaryFrame(entry) is VorbisComment -> handleVorbisComment(entry) + else -> null } - // Sanitize input - lyrics = lyrics?.replace("\u0000", "") + if (result !== null && result.lyrics !== null) { + // Sanitize input before returning it. + lyrics = result.lyrics.replace("\u0000", "") + isSync = result.isSync + } } - private fun handleTextInformationFrame(frame: TextInformationFrame) { + //#region [Lyrics Containers] + private fun handleTextInformationFrame(frame: TextInformationFrame): ParsedResult? { val tagName = frame.id.uppercase() - if (tagName !in ID3v2_LYRIC_TAGS) return - lyrics = frame.values.firstOrNull() - isSync = tagName == "SYLT" + if (tagName !in ID3v2_LYRIC_TAGS) return null + return ParsedResult(frame.values.firstOrNull(), tagName == "SYLT") } - private fun handleBinaryFrame(frame: BinaryFrame) { + private fun handleBinaryFrame(frame: BinaryFrame): ParsedResult? { val tagName = frame.id.uppercase() - if (tagName !in ID3v2_LYRIC_TAGS) return + if (tagName !in ID3v2_LYRIC_TAGS) return null val byteArr = frame.data // The 1st byte in the array determines the encoding in ID3. @@ -57,17 +76,16 @@ class LyricsParser(entry: Metadata.Entry) { else -> Charsets.UTF_8 } - lyrics = String(byteArr, encodingCharSet) - isSync = tagName == "SYLT" + return ParsedResult(String(byteArr, encodingCharSet), tagName == "SYLT") } - private fun handleVorbisComment(comment: VorbisComment) { - if (comment.key.uppercase() != "LYRICS") return + private fun handleVorbisComment(comment: VorbisComment): ParsedResult? { + if (comment.key.uppercase() != "LYRICS") return null // We immediately bail out if we find lyrics in Vorbis Comments due to // there being no specific tag for synchronized lyrics. - lyrics = comment.value - isSync = true + return ParsedResult(comment.value, true) } + //#endregion companion object { private val ID3v2_LYRIC_TAGS = listOf("SYLT", "USLT") diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/MetadataRetrieverModule.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/MetadataRetrieverModule.kt index 1d6cb6c..61a95a1 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/MetadataRetrieverModule.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/MetadataRetrieverModule.kt @@ -13,7 +13,6 @@ import androidx.media3.inspector.MetadataRetriever import com.cyanchill.missingcore.metadataretriever.NativeMetadataRetrieverSpec import com.cyanchill.missingcore.metadataretriever.models.ArtworkOptions import com.cyanchill.missingcore.metadataretriever.models.BridgeReturnables.* -import com.cyanchill.missingcore.metadataretriever.utils.LyricsParser import com.cyanchill.missingcore.metadataretriever.utils.MapUtils import com.cyanchill.missingcore.metadataretriever.utils.Normalization import com.cyanchill.missingcore.metadataretriever.utils.ReplayGainParser @@ -222,23 +221,7 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : override fun getLyric(uri: String, promise: Promise) { safeExecuteOnURI(uri, "ERR_LYRIC", promise) { val metadataList = getMetadataList(getFormatList(uri)) - var parsedLyrics: LyricsParser? = null - - for (metadata in metadataList) { - val numEntries = metadata.length() - // Manually iterate over metadata entries to find a supported key. - for (i in 0 until numEntries) { - val lyricsCandidate = LyricsParser(metadata[i]) - if (lyricsCandidate.lyrics != null) { - parsedLyrics = lyricsCandidate - if (parsedLyrics.isSync) break - } - } - - if (parsedLyrics?.isSync == true) break - } - - promise.resolve(parsedLyrics?.lyrics) + promise.resolve(LyricsParser(metadataList).lyrics) } } From 682334b184b2808ef5aa9b21a8dc6df552cb3c26 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:54:28 -0400 Subject: [PATCH 2/7] refactor: Do the same thing with `ReplayGainParser` - In the future, we may want to change up what we expose in `ReplayGainParser`, for example: `trackGain` (prev `gain`), `trackPeak`, `albumGain`, and `albumPeak`. --- .../modules/MetadataRetrieverModule.kt | 17 ++-------------- .../{utils => modules}/ReplayGainParser.kt | 20 ++++++++++++++++--- 2 files changed, 19 insertions(+), 18 deletions(-) rename android/src/main/java/com/cyanchill/missingcore/metadataretriever/{utils => modules}/ReplayGainParser.kt (76%) diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/MetadataRetrieverModule.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/MetadataRetrieverModule.kt index 61a95a1..b09691c 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/MetadataRetrieverModule.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/MetadataRetrieverModule.kt @@ -15,7 +15,6 @@ import com.cyanchill.missingcore.metadataretriever.models.ArtworkOptions import com.cyanchill.missingcore.metadataretriever.models.BridgeReturnables.* import com.cyanchill.missingcore.metadataretriever.utils.MapUtils import com.cyanchill.missingcore.metadataretriever.utils.Normalization -import com.cyanchill.missingcore.metadataretriever.utils.ReplayGainParser import com.cyanchill.missingcore.metadataretriever.utils.safeExecuteOnURI import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise @@ -225,23 +224,11 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : } } + /** Returns the embedded track ReplayGain. */ override fun getR128Gain(uri: String, promise: Promise) { safeExecuteOnURI(uri, "ERR_REPLAY_GAIN", promise) { val metadataList = getMetadataList(getFormatList(uri)) - var gain: Float? = null - - for (metadata in metadataList) { - val numEntries = metadata.length() - // Manually iterate over metadata entries to find a supported key. - for (i in 0 until numEntries) { - gain = ReplayGainParser(metadata[i]).gain - if (gain != null) break - } - - if (gain != null) break - } - - promise.resolve(gain) + promise.resolve(ReplayGainParser(metadataList).gain) } } diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/ReplayGainParser.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ReplayGainParser.kt similarity index 76% rename from android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/ReplayGainParser.kt rename to android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ReplayGainParser.kt index 739bea0..cfcc32c 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/ReplayGainParser.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ReplayGainParser.kt @@ -1,4 +1,4 @@ -package com.cyanchill.missingcore.metadataretriever.utils +package com.cyanchill.missingcore.metadataretriever.modules import androidx.annotation.OptIn import androidx.media3.common.Metadata @@ -8,11 +8,21 @@ import androidx.media3.extractor.metadata.vorbis.VorbisComment import androidx.media3.extractor.mp3.Mp3InfoReplayGain @OptIn(UnstableApi::class) -class ReplayGainParser(entry: Metadata.Entry) { +class ReplayGainParser(metadataList: List) { var gain: Float? = null init { - // Extract track gain based on the class. + for (metadata in metadataList) { + val numEntries = metadata.length() + for (i in 0 until numEntries) { + parseMetadataEntry(metadata[i]) + if (gain != null) break + } + if (gain != null) break + } + } + + private fun parseMetadataEntry(entry: Metadata.Entry) { gain = when (entry) { is TextInformationFrame -> handleTextInformationFrame(entry) is Mp3InfoReplayGain -> handleMp3InfoReplayGain(entry) @@ -21,6 +31,7 @@ class ReplayGainParser(entry: Metadata.Entry) { } } + //#region [ReplayGain Containers] private fun handleTextInformationFrame(frame: TextInformationFrame): Float? = when (frame.description?.uppercase()) { "REPLAYGAIN_TRACK_GAIN" -> frame.values.firstOrNull()?.parseReplayGainAdjustment() @@ -41,8 +52,11 @@ class ReplayGainParser(entry: Metadata.Entry) { "R128_TRACK_GAIN" -> comment.value.parseReplayGainAdjustment()?.div(256f) else -> null } + //#endregion + //#region [Internal Overloads] /** Some replay gain tags include "dB" in the string. */ private fun String.parseReplayGainAdjustment() = replace(Regex("[^\\d.-]"), "").toFloatOrNull() + //#endregion } From 69f0c3988433e0965a6cf7d0068fc6438477380d Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:42:05 -0400 Subject: [PATCH 3/7] fix: Random junk in front of returned lyrics - This only occurs for ID3 binary frames. --- CHANGELOG.md | 4 ++++ .../metadataretriever/modules/LyricsParser.kt | 23 +++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dd45e6..72518aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ ## [Unreleased] +### 🛠️ Fixes + +- Random junk in front of lyrics returned by `getLyric()` if they're in an ID3 binary frame. + ## [3.2.0] - 2026-06-13 ### 🎉 Added diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt index 9a3b992..9bc8253 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt @@ -54,11 +54,12 @@ class LyricsParser(metadataList: List) { val tagName = frame.id.uppercase() if (tagName !in ID3v2_LYRIC_TAGS) return null - val byteArr = frame.data + var byteArr = frame.data // The 1st byte in the array determines the encoding in ID3. // - Mp3Tag doesn't specify a Byte Order Mark if it's `1` (UTF-16), so we'll do a heuristic guess for what charset to use. // - Ref: https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-structure.html#id3v2-frame-overview - val encodingCharSet = when (byteArr[0].toString()) { + val encodingByte = byteArr[0].toString() + val encodingCharSet = when (encodingByte) { "0" -> Charsets.ISO_8859_1 "1" -> { if (byteArr[1] == BYTE_0xFE && byteArr[2] == BYTE_0xFF) { @@ -76,6 +77,23 @@ class LyricsParser(metadataList: List) { else -> Charsets.UTF_8 } + val twoByteNull = encodingByte == "1" || encodingByte == "2" + // `USLT` starts with 4 bytes of "junk" while `SYLT` starts with 6 bytes. + // - https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-frames.html#unsynchronised-lyrics-text-transcription + // - https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-frames.html#synchronised-lyrics-text + byteArr = byteArr.drop(if (tagName == "USLT") 4 else 6).toByteArray() + // We then have a "content descriptor", which ends with either 1 or 2 null bytes (based on the text encoding). + var droppedBytes = 0 + for (i in 0 until byteArr.size - 1) { + if (byteArr[i] == BYTE_0x00) { + if (!twoByteNull) break + if (byteArr[i + 1] == BYTE_0x00) break + } + droppedBytes += 1 + if (twoByteNull && i == byteArr.size - 2) break + } + byteArr = byteArr.drop(droppedBytes + if (twoByteNull) 2 else 1).toByteArray() + return ParsedResult(String(byteArr, encodingCharSet), tagName == "SYLT") } @@ -89,6 +107,7 @@ class LyricsParser(metadataList: List) { companion object { private val ID3v2_LYRIC_TAGS = listOf("SYLT", "USLT") + private const val BYTE_0x00 = 0x00.toByte() private const val BYTE_0xFE = 0xFE.toByte() private const val BYTE_0xFF = 0xFF.toByte() } From 79083b734a65f93689ab1dbde3dce04884332e20 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:49:42 -0400 Subject: [PATCH 4/7] fix: Sanitization is no longer needed - This was due to the null bytes that we didn't handle before. --- .../missingcore/metadataretriever/modules/LyricsParser.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt index 9bc8253..597e201 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt @@ -37,8 +37,7 @@ class LyricsParser(metadataList: List) { } if (result !== null && result.lyrics !== null) { - // Sanitize input before returning it. - lyrics = result.lyrics.replace("\u0000", "") + lyrics = result.lyrics isSync = result.isSync } } From 71c4bc9de6b284ae74c7fe079992d070d7665998 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 21 Jun 2026 00:58:58 -0400 Subject: [PATCH 5/7] chore: Note refactor in changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72518aa..0e01884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ - Random junk in front of lyrics returned by `getLyric()` if they're in an ID3 binary frame. +### ⚙️ Internal Changes + +- Rewrite `LyricsParser` & `ReplayGainParser`. + ## [3.2.0] - 2026-06-13 ### 🎉 Added From decbd80d1194cda491eaa0be8f78ee62a91724bb Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:14:40 -0400 Subject: [PATCH 6/7] fix: How we determine encoding if encoding byte is 1 - We were previously checking the bytes representing "language", which isn't encoded. --- .../metadataretriever/modules/LyricsParser.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt index 597e201..2ad605c 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt @@ -58,6 +58,12 @@ class LyricsParser(metadataList: List) { // - Mp3Tag doesn't specify a Byte Order Mark if it's `1` (UTF-16), so we'll do a heuristic guess for what charset to use. // - Ref: https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-structure.html#id3v2-frame-overview val encodingByte = byteArr[0].toString() + // `USLT` starts with 4 bytes of "junk" while `SYLT` starts with 6 bytes. + // - https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-frames.html#unsynchronised-lyrics-text-transcription + // - https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-frames.html#synchronised-lyrics-text + val headerSize = if (tagName == "USLT") 4 else 6 + // Figure out the BOM used by checking the encoding on the "Content Descriptor" section. + byteArr = byteArr.drop(headerSize).toByteArray() val encodingCharSet = when (encodingByte) { "0" -> Charsets.ISO_8859_1 "1" -> { @@ -77,10 +83,6 @@ class LyricsParser(metadataList: List) { } val twoByteNull = encodingByte == "1" || encodingByte == "2" - // `USLT` starts with 4 bytes of "junk" while `SYLT` starts with 6 bytes. - // - https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-frames.html#unsynchronised-lyrics-text-transcription - // - https://mutagen-specs.readthedocs.io/en/latest/id3/id3v2.4.0-frames.html#synchronised-lyrics-text - byteArr = byteArr.drop(if (tagName == "USLT") 4 else 6).toByteArray() // We then have a "content descriptor", which ends with either 1 or 2 null bytes (based on the text encoding). var droppedBytes = 0 for (i in 0 until byteArr.size - 1) { From 6793d0b88d263383ecabc01c5cf00ced51e31904 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:50:46 -0400 Subject: [PATCH 7/7] fix: Re-add removal of `\u0000` character - This is due to potentially being at the end of the returned lyrics. --- .../missingcore/metadataretriever/modules/LyricsParser.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt index 2ad605c..15ebe4f 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt @@ -37,7 +37,8 @@ class LyricsParser(metadataList: List) { } if (result !== null && result.lyrics !== null) { - lyrics = result.lyrics + // `null` byte might still appear at the end of the lyrics. + lyrics = result.lyrics.replace("\u0000", "") isSync = result.isSync } }