diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dd45e6..0e01884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ 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. + +### ⚙️ Internal Changes + +- Rewrite `LyricsParser` & `ReplayGainParser`. + ## [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 new file mode 100644 index 0000000..15ebe4f --- /dev/null +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/LyricsParser.kt @@ -0,0 +1,116 @@ +package com.cyanchill.missingcore.metadataretriever.modules + +import androidx.annotation.OptIn +import androidx.media3.common.Metadata +import androidx.media3.common.util.UnstableApi +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(metadataList: List) { + private var isSync = false + var lyrics: String? = null + + init { + 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 + } + + if (result !== null && result.lyrics !== null) { + // `null` byte might still appear at the end of the lyrics. + lyrics = result.lyrics.replace("\u0000", "") + isSync = result.isSync + } + } + + //#region [Lyrics Containers] + private fun handleTextInformationFrame(frame: TextInformationFrame): ParsedResult? { + val tagName = frame.id.uppercase() + if (tagName !in ID3v2_LYRIC_TAGS) return null + return ParsedResult(frame.values.firstOrNull(), tagName == "SYLT") + } + + private fun handleBinaryFrame(frame: BinaryFrame): ParsedResult? { + val tagName = frame.id.uppercase() + if (tagName !in ID3v2_LYRIC_TAGS) return null + + 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 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" -> { + if (byteArr[1] == BYTE_0xFE && byteArr[2] == BYTE_0xFF) { + Charsets.UTF_16BE + } else if (byteArr[1] == BYTE_0xFF && byteArr[2] == BYTE_0xFE) { + Charsets.UTF_16LE + } else { + // Heuristic guess of byte order using new line character. + // - If the conversion contains a newline character, that charset should be used. + if (String(byteArr, Charsets.UTF_16LE).contains("\n")) Charsets.UTF_16LE + else Charsets.UTF_16BE + } + } + "2" -> Charsets.UTF_16BE + else -> Charsets.UTF_8 + } + + val twoByteNull = encodingByte == "1" || encodingByte == "2" + // 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") + } + + 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. + return ParsedResult(comment.value, true) + } + //#endregion + + 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() + } +} 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..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 @@ -13,10 +13,8 @@ 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 import com.cyanchill.missingcore.metadataretriever.utils.safeExecuteOnURI import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise @@ -222,43 +220,15 @@ 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) } } + /** 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 } diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt deleted file mode 100644 index 69a7f75..0000000 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt +++ /dev/null @@ -1,77 +0,0 @@ -package com.cyanchill.missingcore.metadataretriever.utils - -import androidx.annotation.OptIn -import androidx.media3.common.Metadata -import androidx.media3.common.util.UnstableApi -import androidx.media3.extractor.metadata.id3.BinaryFrame -import androidx.media3.extractor.metadata.id3.TextInformationFrame -import androidx.media3.extractor.metadata.vorbis.VorbisComment - -@OptIn(UnstableApi::class) -class LyricsParser(entry: Metadata.Entry) { - var lyrics: String? = null - var isSync = false - - init { - // Extra lyrics based on the class. - when (entry) { - is TextInformationFrame -> handleTextInformationFrame(entry) - is BinaryFrame -> handleBinaryFrame(entry) - is VorbisComment -> handleVorbisComment(entry) - } - - // Sanitize input - lyrics = lyrics?.replace("\u0000", "") - } - - private fun handleTextInformationFrame(frame: TextInformationFrame) { - val tagName = frame.id.uppercase() - if (tagName !in ID3v2_LYRIC_TAGS) return - lyrics = frame.values.firstOrNull() - isSync = tagName == "SYLT" - } - - private fun handleBinaryFrame(frame: BinaryFrame) { - val tagName = frame.id.uppercase() - if (tagName !in ID3v2_LYRIC_TAGS) return - - val 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()) { - "0" -> Charsets.ISO_8859_1 - "1" -> { - if (byteArr[1] == BYTE_0xFE && byteArr[2] == BYTE_0xFF) { - Charsets.UTF_16BE - } else if (byteArr[1] == BYTE_0xFF && byteArr[2] == BYTE_0xFE) { - Charsets.UTF_16LE - } else { - // Heuristic guess of byte order using new line character. - // - If the conversion contains a newline character, that charset should be used. - if (String(byteArr, Charsets.UTF_16LE).contains("\n")) Charsets.UTF_16LE - else Charsets.UTF_16BE - } - } - "2" -> Charsets.UTF_16BE - else -> Charsets.UTF_8 - } - - lyrics = String(byteArr, encodingCharSet) - isSync = tagName == "SYLT" - } - - private fun handleVorbisComment(comment: VorbisComment) { - if (comment.key.uppercase() != "LYRICS") return - // 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 - } - - companion object { - private val ID3v2_LYRIC_TAGS = listOf("SYLT", "USLT") - private const val BYTE_0xFE = 0xFE.toByte() - private const val BYTE_0xFF = 0xFF.toByte() - } -}