From 1a4781f54c4297d2ef022ea454def90247b52a56 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:01:15 -0400 Subject: [PATCH 1/6] refactor: Move lyric parser into its own file --- .../modules/MetadataRetrieverModule.kt | 62 ++------------- .../metadataretriever/utils/LyricsParser.kt | 77 +++++++++++++++++++ 2 files changed, 83 insertions(+), 56 deletions(-) create mode 100644 android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt 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 86c8282..74787fc 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 @@ -9,13 +9,11 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata 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 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 @@ -233,63 +231,20 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : override fun getLyric(uri: String, promise: Promise) { try { val metadataList = getMetadataList(getFormatList(uri)) - - var lyricsStr: String? = null - var isLyricsSync = false + 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 metadataEntry = metadata[i] - - if (metadataEntry is VorbisComment && metadataEntry.key.uppercase() == "LYRICS") { - lyricsStr = metadataEntry.value - isLyricsSync = true - } else if ( - (metadataEntry is TextInformationFrame || metadataEntry is BinaryFrame) && - metadataEntry.id.uppercase() in ID3v2_LYRIC_TAGS - ) { - lyricsStr = when (metadataEntry) { - is TextInformationFrame -> metadataEntry.values.firstOrNull() - is BinaryFrame -> { - val byteArr = metadataEntry.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 - } - String(byteArr, encodingCharSet) - } - else -> null - } - isLyricsSync = metadataEntry.id.uppercase() === "SYLT" - } - - if (isLyricsSync) break + parsedLyrics = LyricsParser(metadata[i]) + if (parsedLyrics.isSync) break } - if (isLyricsSync) break + if (parsedLyrics?.isSync == true) break } - if (lyricsStr !== null) lyricsStr = lyricsStr.replace("\u0000", "") - - promise.resolve(lyricsStr) + promise.resolve(parsedLyrics?.lyrics) } catch (e: ExecutionException) { val isWantedException = e.message?.contains("androidx.media3.datasource.FileDataSource\$FileDataSourceException") @@ -406,10 +361,5 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : companion object { const val NAME = NativeMetadataRetrieverSpec.NAME - - // We'll only support embedded lyrics in ID3v2.3+ tags. - private val ID3v2_LYRIC_TAGS = listOf("SYLT", "USLT") - 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/utils/LyricsParser.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt new file mode 100644 index 0000000..7ecf176 --- /dev/null +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt @@ -0,0 +1,77 @@ +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?.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() + } +} From a12294f01938df10043c3ba93aabc9444ab6eeab Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:08:55 -0400 Subject: [PATCH 2/6] fix: Ensure we don't override `parsedLyrics` if the new `LyricParser` has no lyrics --- .../metadataretriever/modules/MetadataRetrieverModule.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 74787fc..05c117c 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 @@ -237,8 +237,11 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : val numEntries = metadata.length() // Manually iterate over metadata entries to find a supported key. for (i in 0 until numEntries) { - parsedLyrics = LyricsParser(metadata[i]) - if (parsedLyrics.isSync) break + val lyricsCandidate = LyricsParser(metadata[i]) + if (lyricsCandidate.lyrics != null) { + parsedLyrics = lyricsCandidate + if (parsedLyrics.isSync) break + } } if (parsedLyrics?.isSync == true) break From 90510e7889727071b97887dd49fda77feae1b9b1 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:09:55 -0400 Subject: [PATCH 3/6] fix: Forgot to assign the sanitized value --- .../missingcore/metadataretriever/utils/LyricsParser.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 7ecf176..69a7f75 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt @@ -21,7 +21,7 @@ class LyricsParser(entry: Metadata.Entry) { } // Sanitize input - lyrics?.replace("\u0000", "") + lyrics = lyrics?.replace("\u0000", "") } private fun handleTextInformationFrame(frame: TextInformationFrame) { From 9bd9532232ba67759139a2f4385aa89254be50e9 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:12:31 -0400 Subject: [PATCH 4/6] fix: Don't include byte order mark in result --- .../missingcore/metadataretriever/utils/LyricsParser.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 index 69a7f75..bdc4d78 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt @@ -57,7 +57,13 @@ class LyricsParser(entry: Metadata.Entry) { else -> Charsets.UTF_8 } - lyrics = String(byteArr, encodingCharSet) + // Skip encoding byte; also skip BOM if present for UTF-16 with BOM (encoding 1) + val skipBytes = when (byteArr[0].toString()) { + "1" -> if ((byteArr[1] == BYTE_0xFE && byteArr[2] == BYTE_0xFF) || + (byteArr[1] == BYTE_0xFF && byteArr[2] == BYTE_0xFE)) 3 else 1 + else -> 1 + } + lyrics = String(byteArr, skipBytes, byteArr.size - skipBytes, encodingCharSet) isSync = tagName == "SYLT" } From 5ce4567d9c70102613e4bf41a2c874bd2763fa93 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:23:26 -0400 Subject: [PATCH 5/6] chore: Revert 9bd9532 - This broke things. --- .../missingcore/metadataretriever/utils/LyricsParser.kt | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 index bdc4d78..69a7f75 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/LyricsParser.kt @@ -57,13 +57,7 @@ class LyricsParser(entry: Metadata.Entry) { else -> Charsets.UTF_8 } - // Skip encoding byte; also skip BOM if present for UTF-16 with BOM (encoding 1) - val skipBytes = when (byteArr[0].toString()) { - "1" -> if ((byteArr[1] == BYTE_0xFE && byteArr[2] == BYTE_0xFF) || - (byteArr[1] == BYTE_0xFF && byteArr[2] == BYTE_0xFE)) 3 else 1 - else -> 1 - } - lyrics = String(byteArr, skipBytes, byteArr.size - skipBytes, encodingCharSet) + lyrics = String(byteArr, encodingCharSet) isSync = tagName == "SYLT" } From fc2a52e952acd4b17fc7c0c36906db8efe262d23 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:53:08 -0400 Subject: [PATCH 6/6] refactor: Reusable try-catch block - It handles the error handling if we try to do our logic on a file that doesn't exist. --- .../modules/MetadataRetrieverModule.kt | 49 ++----------------- .../metadataretriever/utils/SafeUtils.kt | 24 +++++++++ 2 files changed, 29 insertions(+), 44 deletions(-) create mode 100644 android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/SafeUtils.kt 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 05c117c..1d6cb6c 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 @@ -17,6 +17,7 @@ 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 import com.facebook.react.bridge.ReactApplicationContext @@ -167,7 +168,7 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : val artworkOptions = ArtworkOptions(options) val asBase64 = artworkOptions.asBase64 - try { + safeExecuteOnURI(uri, "ERR_ARTWORK", promise) { val metadataList = getMetadataList(getFormatList(uri)) // We'll want to return the image designated as "Cover (front)", otherwise return first image found. @@ -214,22 +215,12 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : val imgUri = (coverImage ?: backupImage)?.let { reader.saveImage(it as ByteArray, artworkOptions) } promise.resolve(imgUri) } - } catch (e: ExecutionException) { - val isWantedException = - e.message?.contains("androidx.media3.datasource.FileDataSource\$FileDataSourceException") - ?: false - when (isWantedException) { - true -> promise.reject("ENOENT", "ENOENT: No such file or directory (${uri})", e) - false -> promise.reject("ERR_ARTWORK", e.message, e) - } - } catch (e: Exception) { - promise.reject("ERR_ARTWORK", e.message, e) } } /** Returns embedded lyrics in supported "lyrics" tags. Prefers returning synchronized lyrics. */ override fun getLyric(uri: String, promise: Promise) { - try { + safeExecuteOnURI(uri, "ERR_LYRIC", promise) { val metadataList = getMetadataList(getFormatList(uri)) var parsedLyrics: LyricsParser? = null @@ -248,21 +239,11 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : } promise.resolve(parsedLyrics?.lyrics) - } catch (e: ExecutionException) { - val isWantedException = - e.message?.contains("androidx.media3.datasource.FileDataSource\$FileDataSourceException") - ?: false - when (isWantedException) { - true -> promise.reject("ENOENT", "ENOENT: No such file or directory (${uri})", e) - false -> promise.reject("ERR_LYRIC", e.message, e) - } - } catch (e: Exception) { - promise.reject("ERR_LYRIC", e.message, e) } } override fun getR128Gain(uri: String, promise: Promise) { - try { + safeExecuteOnURI(uri, "ERR_REPLAY_GAIN", promise) { val metadataList = getMetadataList(getFormatList(uri)) var gain: Float? = null @@ -278,16 +259,6 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : } promise.resolve(gain) - } catch (e: ExecutionException) { - val isWantedException = - e.message?.contains("androidx.media3.datasource.FileDataSource\$FileDataSourceException") - ?: false - when (isWantedException) { - true -> promise.reject("ENOENT", "ENOENT: No such file or directory (${uri})", e) - false -> promise.reject("ERR_REPLAY_GAIN", e.message, e) - } - } catch (e: Exception) { - promise.reject("ERR_REPLAY_GAIN", e.message, e) } } @@ -302,7 +273,7 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : val formatStrArr = Arguments.createArray() val metadataStrArr = Arguments.createArray() - try { + safeExecuteOnURI(uri, "ERR_DEBUG", promise) { val formatList = getFormatList(uri) formatList.forEach { item -> formatStrArr.pushString(item.toString()) } @@ -312,16 +283,6 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : returnObj.putArray("format", formatStrArr) returnObj.putArray("metadata", metadataStrArr) promise.resolve(returnObj) - } catch (e: ExecutionException) { - val isWantedException = - e.message?.contains("androidx.media3.datasource.FileDataSource\$FileDataSourceException") - ?: false - when (isWantedException) { - true -> promise.reject("ENOENT", "ENOENT: No such file or directory (${uri})", e) - false -> promise.reject("ERR_DEBUG", e.message, e) - } - } catch (e: Exception) { - promise.reject("ERR_DEBUG", e.message, e) } } diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/SafeUtils.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/SafeUtils.kt new file mode 100644 index 0000000..37b66d1 --- /dev/null +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/SafeUtils.kt @@ -0,0 +1,24 @@ +package com.cyanchill.missingcore.metadataretriever.utils + +import com.facebook.react.bridge.Promise +import java.util.concurrent.ExecutionException + +/** + * Reusable try/catch wrapper for when we do something pertaining to a file URI. Code that executes + * in the "try" portion goes in the provided `block`. + */ +fun safeExecuteOnURI(uri: String, errorKey: String, promise: Promise, block: () -> T) { + try { + block() + } catch (e: ExecutionException) { + val isWantedException = + e.message?.contains("androidx.media3.datasource.FileDataSource\$FileDataSourceException") + ?: false + when (isWantedException) { + true -> promise.reject("ENOENT", "ENOENT: No such file or directory (${uri})", e) + false -> promise.reject(errorKey, e.message, e) + } + } catch (e: Exception) { + promise.reject(errorKey, e.message, e) + } +}