Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Metadata>) {
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
Comment thread
cyanChill marked this conversation as resolved.
Comment thread
cyanChill marked this conversation as resolved.
} 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)
}
Comment thread
cyanChill marked this conversation as resolved.
//#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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Metadata>) {
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)
Expand All @@ -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()
Expand All @@ -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
}

This file was deleted.

Loading