From b37f93b36bf4dd5531b50602d87b1e24b4b0d1e7 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sat, 27 Jun 2026 22:14:53 -0400 Subject: [PATCH 01/18] chore: Initial API idea for function to identify reused artwork by their hash --- src/NativeMetadataRetriever.ts | 22 ++++++++++++++++++++++ src/index.ts | 18 +++++++++++++++++- src/types/ArtworkOptions.ts | 15 +++++++++++++-- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/NativeMetadataRetriever.ts b/src/NativeMetadataRetriever.ts index 907ccc5..a969a83 100644 --- a/src/NativeMetadataRetriever.ts +++ b/src/NativeMetadataRetriever.ts @@ -24,6 +24,24 @@ type ArtworkOptions = { saveUri?: string; }; +/** Extra options for when using `getHashedArtwork`. */ +type HashedArtworkOptions = { + /** + * A value in the range `0.0` - `1.0` specifying the quality of the resulting image. + * - Defaults to `1`. + */ + compress?: number; + /** + * Specifies the format the image will be saved in. + * - Defaults to `SaveFormat.JPEG`. + */ + format?: 'jpeg' | 'png' | 'webp'; + /** An array of known MD5 hashes formatted as a 32-character hexadecimal string. */ + knownHashes: string[]; + /** Directory where we want to save the hashed image. The file name will be the hash. */ + saveDirectory: string; +}; + /** Options that can be set to modify the behavior of the package. */ type ConfigOptions = { /** @@ -59,6 +77,10 @@ export interface Spec extends TurboModule { uri: string, options: ArtworkOptions & { base64?: boolean } ): Promise; + getHashedArtwork( + uri: string, + options: HashedArtworkOptions + ): Promise<{ hash: string; uri: string } | null>; getLyric(uri: string): Promise; diff --git a/src/index.ts b/src/index.ts index bb7f6fb..a655661 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,10 @@ import MetadataRetriever from './MetadataRetriever'; import { MetadataPresets } from './constants'; -import type { ArtworkOptions } from './types/ArtworkOptions'; +import type { + ArtworkOptions, + HashedArtworkOptions, +} from './types/ArtworkOptions'; import { SaveFormat } from './types/ArtworkOptions'; import type { ConfigOptions } from './types/ConfigOptions'; import type { BulkMetadata, MediaMetadataExcerpt } from './types/GetMetadata'; @@ -61,6 +64,18 @@ export async function saveArtwork( ): Promise { return MetadataRetriever.getArtwork(uri, options ?? {}); } + +/** + * Returns the hash & uri of the saved artwork. + * - Ignores the hard-limit on the max size of the image that can be saved. + * - The hash is based off the raw ByteArray before any formatting. + */ +export async function saveHashedArtwork( + uri: string, + options: HashedArtworkOptions +): Promise<{ hash: string; uri: string } | null> { + return MetadataRetriever.getHashedArtwork(uri, options); +} //#endregion //#region Get Lyric @@ -98,6 +113,7 @@ export { type ArtworkOptions, type BulkMetadata, type ConfigOptions, + type HashedArtworkOptions, type MediaMetadata, type MediaMetadataExcerpt, type MediaMetadataPublicField, diff --git a/src/types/ArtworkOptions.ts b/src/types/ArtworkOptions.ts index f53953e..aa1262c 100644 --- a/src/types/ArtworkOptions.ts +++ b/src/types/ArtworkOptions.ts @@ -8,8 +8,7 @@ export const SaveFormat = { export type SaveFormat = ObjectValues; -/** Extra options for when using `getArtwork`. */ -export type ArtworkOptions = { +type SharedArtworkOptions = { /** * A value in the range `0.0` - `1.0` specifying the quality of the resulting image. * - Defaults to `1`. @@ -20,6 +19,18 @@ export type ArtworkOptions = { * - Defaults to `SaveFormat.JPEG`. */ format?: SaveFormat; +}; + +/** Extra options for when using `getArtwork`. */ +export type ArtworkOptions = SharedArtworkOptions & { /** Uri we want to save the artwork to instead of the cache directory. */ saveUri?: string; }; + +/** Extra options for when using `getHashedArtwork`. */ +export type HashedArtworkOptions = SharedArtworkOptions & { + /** An array of known MD5 hashes formatted as a 32-character hexadecimal string. */ + knownHashes: string[]; + /** Directory where we want to save the hashed image. The file name will be the hash. */ + saveDirectory: string; +}; From 6d09be4c00c953f16c803c2f8f344e7f4bf87660 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:02:54 -0400 Subject: [PATCH 02/18] chore: Functional implementation of `getHashedArtwork` --- .../models/ArtworkOptions.kt | 25 +++++ .../modules/MetadataRetrieverModule.kt | 101 ++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt index aaf1468..8c5484e 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt @@ -31,6 +31,31 @@ class ArtworkOptions(options: ReadableMap) { } } +class HashedArtworkOptions(options: ReadableMap) { + /** A value in the range `0.0` - `1.0` specifying the quality of the resulting image. */ + val compress: Double + /** Specifies the format the image will be saved in. */ + val format: ImageFormat + /** Location where we want to save the image. */ + val saveDirectory: String + /** A list of know image hashes. */ + val knownHashes: ArrayList + + init { + val optionsBundle = Arguments.toBundle(options) as Bundle + + compress = if (optionsBundle.containsKey("compress")) optionsBundle.getDouble("compress") else 1.0 + // Default format to JPEG if it's not provided. + format = ImageFormat.fromCode(optionsBundle.getString("format")) ?: ImageFormat.JPEG + + // Remove `file://` in `saveUri` if provided. + val uri = optionsBundle.getString("saveDirectory") as String + saveDirectory = Uri.parse(uri).path as String + + knownHashes = optionsBundle.getStringArrayList("knownHashes") as ArrayList + } +} + enum class ImageFormat(val code: String) { JPEG("jpeg"), JPG("jpg"), 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 b09691c..44be9ec 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 @@ -1,6 +1,8 @@ package com.cyanchill.missingcore.metadataretriever.modules +import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever +import android.net.Uri import android.os.Bundle import androidx.annotation.OptIn import androidx.media3.common.C @@ -13,6 +15,7 @@ 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.models.HashedArtworkOptions import com.cyanchill.missingcore.metadataretriever.utils.MapUtils import com.cyanchill.missingcore.metadataretriever.utils.Normalization import com.cyanchill.missingcore.metadataretriever.utils.safeExecuteOnURI @@ -21,6 +24,10 @@ import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap +import com.facebook.react.util.RNLog +import java.io.File +import java.io.FileOutputStream +import java.security.MessageDigest import java.util.concurrent.ExecutionException @OptIn(UnstableApi::class) @@ -216,6 +223,91 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : } } + override fun getHashedArtwork(uri: String, options: ReadableMap, promise: Promise) { + val artworkOptions = HashedArtworkOptions(options) + + safeExecuteOnURI(uri, "ERR_HASHED_ARTWORK", promise) { + val metadataList = getMetadataList(getFormatList(uri)) + + // We'll want to return the image designated as "Cover (front)", otherwise return first image found. + var coverImage: ByteArray? = null + var coverImageHash: String? = null + var backupImage: ByteArray? = null + var backupImageHash: String? = null + + val isFLAC = uri.endsWith(".flac") || uri.endsWith(".m4a") || uri.endsWith(".mp4") + + // Fallback to `MediaMetadataRetriever` if we find nothing with `MetadataRetriever` or with + // flac/mp4/m4a files due to artwork not being parsed correctly. + // - https://github.com/MissingCore/Music/issues/432 + if (metadataList.isEmpty() || isFLAC) { + val mmrMetadata = MediaMetadataRetriever() + mmrMetadata.setDataSource(Normalization.getSafeUri(uri)) + coverImage = mmrMetadata.embeddedPicture + coverImageHash = mmrMetadata.embeddedPicture?.toMd5Hex() + mmrMetadata.release() + } + + for (metadataItem in metadataList) { + val mediaMetadata = MediaMetadata.Builder() + .populateFromMetadata(metadataItem) + .build() + + when (mediaMetadata.artworkDataType) { + // "Cover (front)" Picture Type + MediaMetadata.PICTURE_TYPE_FRONT_COVER -> { + coverImage = mediaMetadata.artworkData + coverImageHash = mediaMetadata.artworkData?.toMd5Hex() + } + // Fallback to 1st image found. + else -> { + if (backupImage == null) { + backupImage = mediaMetadata.artworkData + backupImageHash = mediaMetadata.artworkData?.toMd5Hex() + } + } + } + + if (coverImage !== null) break + } + + val usedArtwork = coverImage ?: backupImage + val usedHash = coverImageHash ?: backupImageHash + val savePath = "${artworkOptions.saveDirectory}${File.separator}$usedHash${artworkOptions.format.fileExtension}" + + RNLog.w(context, "ByteArraySize: ${usedArtwork?.size}, Hash: $usedHash") + if (usedHash == null || usedArtwork == null) { + RNLog.w(context, "No hash or artwork") + promise.resolve(null) + } else { + val expectedOutput = Arguments.createMap().apply { + putString("hash", usedHash) + putString("uri", Uri.fromFile(File(savePath)).toString()) + } + + if (usedHash in artworkOptions.knownHashes) { + promise.resolve(expectedOutput) + } else { + try { + val bitmap = BitmapFactory.decodeByteArray(usedArtwork, 0, usedArtwork.size) + FileOutputStream(savePath).use { fos -> + bitmap.compress( + artworkOptions.format.compressFormat, + (artworkOptions.compress * 100).toInt(), + fos, + ) + fos.flush() + } + promise.resolve(expectedOutput) + } catch (e: Exception) { + RNLog.w(context, "${e.message}") + promise.resolve(null) + } + } + } + } + } + /** Returns embedded lyrics in supported "lyrics" tags. Prefers returning synchronized lyrics. */ override fun getLyric(uri: String, promise: Promise) { safeExecuteOnURI(uri, "ERR_LYRIC", promise) { @@ -293,6 +385,15 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : } //#endregion + //#region [Internal Overloads] + /** Get an MD5 hash as a 32-character hexadecimal string. */ + fun ByteArray.toMd5Hex(): String { + val md = MessageDigest.getInstance("MD5") + val digest = md.digest(this) + return digest.joinToString("") { "%02x".format(it) } + } + //#endregion + companion object { const val NAME = NativeMetadataRetrieverSpec.NAME } From ae9df3a1a910cd2a084e40c9918e87381ea27af5 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:06:00 -0400 Subject: [PATCH 03/18] chore(deps): Add `expo-file-system` as a dependency for the example app - We're currently testing `getHashedArtwork` out in uncommitted files, which requires using `expo-file-system` to create the image directory. --- example/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/example/package.json b/example/package.json index 7e4b972..9f334ca 100644 --- a/example/package.json +++ b/example/package.json @@ -14,6 +14,7 @@ "@shopify/flash-list": "2.3.2", "@tanstack/react-query": "5.101.0", "expo": "56.0.12", + "expo-file-system": "56.0.8", "expo-image": "56.0.11", "expo-media-library": "56.0.7", "expo-status-bar": "56.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22ddea8..a7adc28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: expo: specifier: 56.0.12 version: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/jest-preset@0.85.3(@babel/core@7.29.7)(react@19.2.3))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(typescript@6.0.3) + expo-file-system: + specifier: 56.0.8 + version: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/jest-preset@0.85.3(@babel/core@7.29.7)(react@19.2.3))(@types/react@19.2.17)(react@19.2.3)) expo-image: specifier: 56.0.11 version: 56.0.11(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/jest-preset@0.85.3(@babel/core@7.29.7)(react@19.2.3))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3) From c62792de9c52387afbd8e4d61e2d03293510dd15 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:09:39 -0400 Subject: [PATCH 04/18] chore: Fix up description for `knownHashes` --- .../metadataretriever/models/ArtworkOptions.kt | 2 +- src/NativeMetadataRetriever.ts | 9 ++++++--- src/types/ArtworkOptions.ts | 7 +++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt index 8c5484e..53401bf 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt @@ -38,7 +38,7 @@ class HashedArtworkOptions(options: ReadableMap) { val format: ImageFormat /** Location where we want to save the image. */ val saveDirectory: String - /** A list of know image hashes. */ + /** A list of know image hashes that are stored in `saveDirectory`. */ val knownHashes: ArrayList init { diff --git a/src/NativeMetadataRetriever.ts b/src/NativeMetadataRetriever.ts index a969a83..a026b9d 100644 --- a/src/NativeMetadataRetriever.ts +++ b/src/NativeMetadataRetriever.ts @@ -36,10 +36,13 @@ type HashedArtworkOptions = { * - Defaults to `SaveFormat.JPEG`. */ format?: 'jpeg' | 'png' | 'webp'; - /** An array of known MD5 hashes formatted as a 32-character hexadecimal string. */ - knownHashes: string[]; - /** Directory where we want to save the hashed image. The file name will be the hash. */ + /** Directory where we want to save the hashed image. The file name will be its hash. */ saveDirectory: string; + /** + * An array of known MD5 hashes formatted as a 32-character hexadecimal string + * which are stored in `saveDirectory`. + */ + knownHashes: string[]; }; /** Options that can be set to modify the behavior of the package. */ diff --git a/src/types/ArtworkOptions.ts b/src/types/ArtworkOptions.ts index aa1262c..e6eaac6 100644 --- a/src/types/ArtworkOptions.ts +++ b/src/types/ArtworkOptions.ts @@ -29,8 +29,11 @@ export type ArtworkOptions = SharedArtworkOptions & { /** Extra options for when using `getHashedArtwork`. */ export type HashedArtworkOptions = SharedArtworkOptions & { - /** An array of known MD5 hashes formatted as a 32-character hexadecimal string. */ - knownHashes: string[]; /** Directory where we want to save the hashed image. The file name will be the hash. */ saveDirectory: string; + /** + * An array of known MD5 hashes formatted as a 32-character hexadecimal string + * which are stored in `saveDirectory`. + */ + knownHashes: string[]; }; From 06d02d2054965c7b3cb19d4d2b869563cceed52e Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:51:12 -0400 Subject: [PATCH 05/18] refactor: Export a single class to handle both iteration of getting artwork - We want this data class to be immutable, so in order to generate the `saveUri` from a hash, we need to expose a function to return a new `ArtworkOptions` instance. --- .../models/ArtworkOptions.kt | 85 +++++++++---------- .../modules/MetadataRetrieverModule.kt | 37 ++++---- 2 files changed, 57 insertions(+), 65 deletions(-) diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt index 53401bf..50ccaca 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt @@ -5,54 +5,51 @@ import android.net.Uri import android.os.Bundle import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReadableMap +import java.io.File -class ArtworkOptions(options: ReadableMap) { - /** If we want to return the artwork as a base64 string. */ - val asBase64: Boolean - - /** A value in the range `0.0` - `1.0` specifying the quality of the resulting image. */ - val compress: Double - /** Specifies the format the image will be saved in. */ - val format: ImageFormat - /** Location where we want to save the image. */ - val saveUri: String? - - init { - val optionsBundle = Arguments.toBundle(options) as Bundle - asBase64 = optionsBundle.getBoolean("base64") - - compress = if (optionsBundle.containsKey("compress")) optionsBundle.getDouble("compress") else 1.0 - // Default format to JPEG if it's not provided. - format = ImageFormat.fromCode(optionsBundle.getString("format")) ?: ImageFormat.JPEG - - // Remove `file://` in `saveUri` if provided. - val uri = optionsBundle.getString("saveUri") - saveUri = if (uri != null) Uri.parse(uri).path else null +data class ArtworkOptions( + /** If the image should be returned as a base64 string. */ + val base64: Boolean = false, + /** Value in the range of `0.0` - `1.0` specifying the quality of the resulting image. */ + val compress: Double = 1.0, + /** Format the image will be saved in. */ + val format: ImageFormat = ImageFormat.JPEG, + /** + * [Only for Non-Image Hashing Strategy] + * "Location" artwork will be saved to (includes file name + extension). + */ + val saveUri: String?, + /** + * [Only for Image Hashing Strategy] + * The directory the file will be saved to. + */ + val saveDirectory: String?, + /** + * [Only for Image Hashing Strategy] + * A list of image hashes inside of `saveDirectory`. + */ + val knownHashes: ArrayList? +) { + fun withGeneratedSaveUri(hash: String): ArtworkOptions { + if (saveDirectory == null) throw IllegalStateException("`saveDirectory` must be defined in order to call `generateSaveUriFromHash`.") + return this.copy( + saveUri = "${saveDirectory}${File.separator}$hash${format.fileExtension}", + ) } -} -class HashedArtworkOptions(options: ReadableMap) { - /** A value in the range `0.0` - `1.0` specifying the quality of the resulting image. */ - val compress: Double - /** Specifies the format the image will be saved in. */ - val format: ImageFormat - /** Location where we want to save the image. */ - val saveDirectory: String - /** A list of know image hashes that are stored in `saveDirectory`. */ - val knownHashes: ArrayList - - init { - val optionsBundle = Arguments.toBundle(options) as Bundle - - compress = if (optionsBundle.containsKey("compress")) optionsBundle.getDouble("compress") else 1.0 - // Default format to JPEG if it's not provided. - format = ImageFormat.fromCode(optionsBundle.getString("format")) ?: ImageFormat.JPEG - - // Remove `file://` in `saveUri` if provided. - val uri = optionsBundle.getString("saveDirectory") as String - saveDirectory = Uri.parse(uri).path as String + companion object { + fun fromReadableMap(options: ReadableMap): ArtworkOptions { + val optionsBundle = Arguments.toBundle(options) as Bundle - knownHashes = optionsBundle.getStringArrayList("knownHashes") as ArrayList + return ArtworkOptions( + optionsBundle.getBoolean("base64"), + if (optionsBundle.containsKey("compress")) optionsBundle.getDouble("compress") else 1.0, + ImageFormat.fromCode(optionsBundle.getString("format")) ?: ImageFormat.JPEG, + optionsBundle.getString("saveUri")?.let { Uri.parse(it).path }, + optionsBundle.getString("saveDirectory")?.let { Uri.parse(it).path }, + optionsBundle.getStringArrayList("knownHashes"), + ) + } } } 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 44be9ec..923a264 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 @@ -1,6 +1,5 @@ package com.cyanchill.missingcore.metadataretriever.modules -import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Bundle @@ -15,7 +14,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.models.HashedArtworkOptions import com.cyanchill.missingcore.metadataretriever.utils.MapUtils import com.cyanchill.missingcore.metadataretriever.utils.Normalization import com.cyanchill.missingcore.metadataretriever.utils.safeExecuteOnURI @@ -26,7 +24,6 @@ import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.util.RNLog import java.io.File -import java.io.FileOutputStream import java.security.MessageDigest import java.util.concurrent.ExecutionException @@ -170,8 +167,8 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : * Either returns the URI to the saved artwork or a base64 image string. */ override fun getArtwork(uri: String, options: ReadableMap, promise: Promise) { - val artworkOptions = ArtworkOptions(options) - val asBase64 = artworkOptions.asBase64 + val artworkOptions = ArtworkOptions.fromReadableMap(options) + val asBase64 = artworkOptions.base64 safeExecuteOnURI(uri, "ERR_ARTWORK", promise) { val metadataList = getMetadataList(getFormatList(uri)) @@ -224,9 +221,15 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : } override fun getHashedArtwork(uri: String, options: ReadableMap, promise: Promise) { - val artworkOptions = HashedArtworkOptions(options) + val artworkOptions = ArtworkOptions.fromReadableMap(options) safeExecuteOnURI(uri, "ERR_HASHED_ARTWORK", promise) { + if (artworkOptions.saveDirectory == null) { + throw IllegalStateException("`saveDirectory` must be defined in order to use `getHashedArtwork`.") + } else if (artworkOptions.knownHashes == null) { + throw IllegalStateException("`knownHashes` must be defined in order to use `getHashedArtwork`.") + } + val metadataList = getMetadataList(getFormatList(uri)) // We'll want to return the image designated as "Cover (front)", otherwise return first image found. @@ -273,35 +276,27 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : val usedArtwork = coverImage ?: backupImage val usedHash = coverImageHash ?: backupImageHash - val savePath = "${artworkOptions.saveDirectory}${File.separator}$usedHash${artworkOptions.format.fileExtension}" RNLog.w(context, "ByteArraySize: ${usedArtwork?.size}, Hash: $usedHash") if (usedHash == null || usedArtwork == null) { RNLog.w(context, "No hash or artwork") promise.resolve(null) } else { + val usableArtworkOptions = artworkOptions.withGeneratedSaveUri(usedHash) + val expectedOutput = Arguments.createMap().apply { putString("hash", usedHash) - putString("uri", Uri.fromFile(File(savePath)).toString()) + putString("uri", Uri.fromFile(File(usableArtworkOptions.saveUri as String)).toString()) } if (usedHash in artworkOptions.knownHashes) { promise.resolve(expectedOutput) } else { - try { - val bitmap = BitmapFactory.decodeByteArray(usedArtwork, 0, usedArtwork.size) - FileOutputStream(savePath).use { fos -> - bitmap.compress( - artworkOptions.format.compressFormat, - (artworkOptions.compress * 100).toInt(), - fos, - ) - fos.flush() - } - promise.resolve(expectedOutput) - } catch (e: Exception) { - RNLog.w(context, "${e.message}") + val imgUri = (coverImage ?: backupImage)?.let { reader.saveImage(it, usableArtworkOptions) } + if (imgUri == null) { promise.resolve(null) + } else { + promise.resolve(expectedOutput) } } } From a375d7d63fbc357cd6c530502a47770c395ae22b Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:05:54 -0400 Subject: [PATCH 06/18] chore: Custom exception for when a required arg is missing for the current function --- .../missingcore/metadataretriever/models/ArtworkOptions.kt | 5 ++++- .../metadataretriever/modules/MetadataRetrieverModule.kt | 5 +++-- .../missingcore/metadataretriever/utils/Exceptions.kt | 4 ++++ 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/Exceptions.kt diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt index 50ccaca..fb4a146 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt @@ -3,6 +3,7 @@ package com.cyanchill.missingcore.metadataretriever.models import android.graphics.Bitmap import android.net.Uri import android.os.Bundle +import com.cyanchill.missingcore.metadataretriever.utils.RequiredArgumentException import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReadableMap import java.io.File @@ -31,7 +32,9 @@ data class ArtworkOptions( val knownHashes: ArrayList? ) { fun withGeneratedSaveUri(hash: String): ArtworkOptions { - if (saveDirectory == null) throw IllegalStateException("`saveDirectory` must be defined in order to call `generateSaveUriFromHash`.") + if (saveDirectory == null) { + throw RequiredArgumentException("withGeneratedSaveUri", "saveDirectory") + } return this.copy( saveUri = "${saveDirectory}${File.separator}$hash${format.fileExtension}", ) 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 923a264..a59ea7b 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 @@ -16,6 +16,7 @@ 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.RequiredArgumentException import com.cyanchill.missingcore.metadataretriever.utils.safeExecuteOnURI import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise @@ -225,9 +226,9 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : safeExecuteOnURI(uri, "ERR_HASHED_ARTWORK", promise) { if (artworkOptions.saveDirectory == null) { - throw IllegalStateException("`saveDirectory` must be defined in order to use `getHashedArtwork`.") + throw RequiredArgumentException("getHashedArtwork", "saveDirectory") } else if (artworkOptions.knownHashes == null) { - throw IllegalStateException("`knownHashes` must be defined in order to use `getHashedArtwork`.") + throw RequiredArgumentException("getHashedArtwork", "knownHashes") } val metadataList = getMetadataList(getFormatList(uri)) diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/Exceptions.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/Exceptions.kt new file mode 100644 index 0000000..a5253a4 --- /dev/null +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/utils/Exceptions.kt @@ -0,0 +1,4 @@ +package com.cyanchill.missingcore.metadataretriever.utils + +class RequiredArgumentException(funName: String, argName: String) : + IllegalArgumentException("`$argName` must be defined in order to use `$funName`.") From a60d2c1583bee701640f6116b10e97244c962799 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:16:22 -0400 Subject: [PATCH 07/18] refactor: Have `getArtwork` from Turbo Modules support all use cases - We do the argument validation in the exposed JS function for `saveHashedArtwork`. --- .../modules/MetadataRetrieverModule.kt | 127 +++++++----------- src/NativeMetadataRetriever.ts | 35 ++--- src/index.ts | 18 ++- 3 files changed, 73 insertions(+), 107 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 a59ea7b..237520a 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 @@ -16,14 +16,12 @@ 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.RequiredArgumentException 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 import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap -import com.facebook.react.util.RNLog import java.io.File import java.security.MessageDigest import java.util.concurrent.ExecutionException @@ -165,7 +163,8 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : * artwork is based on the last `artworkData` found, `getArtwork()` returns the artwork designated * as "Cover (front)" and falls back to the first image found. * - * Either returns the URI to the saved artwork or a base64 image string. + * Returns an object containing the hash of the image ByteArray along with a uri or base64 string + * representing the image. */ override fun getArtwork(uri: String, options: ReadableMap, promise: Promise) { val artworkOptions = ArtworkOptions.fromReadableMap(options) @@ -175,8 +174,11 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : val metadataList = getMetadataList(getFormatList(uri)) // We'll want to return the image designated as "Cover (front)", otherwise return first image found. + // Images are typed as `String?` if returning as base64, `ByteArray?` otherwise. var coverImage: Any? = null + var coverImageHash: String? = null var backupImage: Any? = null + var backupImageHash: String? = null val isFLAC = uri.endsWith(".flac") || uri.endsWith(".m4a") || uri.endsWith(".mp4") @@ -187,6 +189,7 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : val mmrMetadata = MediaMetadataRetriever() mmrMetadata.setDataSource(Normalization.getSafeUri(uri)) coverImage = if (asBase64) reader.getBase64Image(mmrMetadata.embeddedPicture) else mmrMetadata.embeddedPicture + coverImageHash = mmrMetadata.embeddedPicture?.toMd5Hex() mmrMetadata.release() } @@ -199,11 +202,13 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : // "Cover (front)" Picture Type MediaMetadata.PICTURE_TYPE_FRONT_COVER -> { coverImage = if (asBase64) reader.getBase64Image(mediaMetadata.artworkData) else mediaMetadata.artworkData + coverImageHash = mediaMetadata.artworkData?.toMd5Hex() } // Fallback to 1st image found. else -> { if (backupImage == null) { backupImage = if (asBase64) reader.getBase64Image(mediaMetadata.artworkData) else mediaMetadata.artworkData + backupImageHash = mediaMetadata.artworkData?.toMd5Hex() } } } @@ -211,96 +216,54 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : if (coverImage !== null) break } - if (asBase64) { - // `coverImage` or `backupImage` should be a base64 string or `null`. - promise.resolve(coverImage ?: backupImage) - } else { - val imgUri = (coverImage ?: backupImage)?.let { reader.saveImage(it as ByteArray, artworkOptions) } - promise.resolve(imgUri) - } - } - } - - override fun getHashedArtwork(uri: String, options: ReadableMap, promise: Promise) { - val artworkOptions = ArtworkOptions.fromReadableMap(options) + val foundImage = coverImage ?: backupImage + val foundHash = coverImageHash ?: backupImageHash - safeExecuteOnURI(uri, "ERR_HASHED_ARTWORK", promise) { - if (artworkOptions.saveDirectory == null) { - throw RequiredArgumentException("getHashedArtwork", "saveDirectory") - } else if (artworkOptions.knownHashes == null) { - throw RequiredArgumentException("getHashedArtwork", "knownHashes") + if (foundImage == null || foundHash == null) { + // Either both values are defined or not defined. + promise.resolve(null) + return@safeExecuteOnURI } - val metadataList = getMetadataList(getFormatList(uri)) - - // We'll want to return the image designated as "Cover (front)", otherwise return first image found. - var coverImage: ByteArray? = null - var coverImageHash: String? = null - var backupImage: ByteArray? = null - var backupImageHash: String? = null - - val isFLAC = uri.endsWith(".flac") || uri.endsWith(".m4a") || uri.endsWith(".mp4") - - // Fallback to `MediaMetadataRetriever` if we find nothing with `MetadataRetriever` or with - // flac/mp4/m4a files due to artwork not being parsed correctly. - // - https://github.com/MissingCore/Music/issues/432 - if (metadataList.isEmpty() || isFLAC) { - val mmrMetadata = MediaMetadataRetriever() - mmrMetadata.setDataSource(Normalization.getSafeUri(uri)) - coverImage = mmrMetadata.embeddedPicture - coverImageHash = mmrMetadata.embeddedPicture?.toMd5Hex() - mmrMetadata.release() + val returnObj = Arguments.createMap().apply { + putString("hash", foundHash) } - for (metadataItem in metadataList) { - val mediaMetadata = MediaMetadata.Builder() - .populateFromMetadata(metadataItem) - .build() - - when (mediaMetadata.artworkDataType) { - // "Cover (front)" Picture Type - MediaMetadata.PICTURE_TYPE_FRONT_COVER -> { - coverImage = mediaMetadata.artworkData - coverImageHash = mediaMetadata.artworkData?.toMd5Hex() - } - // Fallback to 1st image found. - else -> { - if (backupImage == null) { - backupImage = mediaMetadata.artworkData - backupImageHash = mediaMetadata.artworkData?.toMd5Hex() - } - } - } - - if (coverImage !== null) break + // Case 1: Return base64 image. + if (asBase64) { + returnObj.putString("data", foundImage as String) + promise.resolve(returnObj) + return@safeExecuteOnURI } - val usedArtwork = coverImage ?: backupImage - val usedHash = coverImageHash ?: backupImageHash - - RNLog.w(context, "ByteArraySize: ${usedArtwork?.size}, Hash: $usedHash") - if (usedHash == null || usedArtwork == null) { - RNLog.w(context, "No hash or artwork") - promise.resolve(null) - } else { - val usableArtworkOptions = artworkOptions.withGeneratedSaveUri(usedHash) - - val expectedOutput = Arguments.createMap().apply { - putString("hash", usedHash) - putString("uri", Uri.fromFile(File(usableArtworkOptions.saveUri as String)).toString()) + // Case 2: Return image independently of hash. + if (artworkOptions.saveDirectory == null || artworkOptions.knownHashes == null) { + val imgUri = reader.saveImage(foundImage as ByteArray, artworkOptions) + if (imgUri == null) { + promise.resolve(null) + } else { + returnObj.putString("data", imgUri) + promise.resolve(returnObj) } + return@safeExecuteOnURI + } - if (usedHash in artworkOptions.knownHashes) { - promise.resolve(expectedOutput) - } else { - val imgUri = (coverImage ?: backupImage)?.let { reader.saveImage(it, usableArtworkOptions) } - if (imgUri == null) { - promise.resolve(null) - } else { - promise.resolve(expectedOutput) - } + // Case 3: Return image with respect to hash. + val hashedArtworkOptions = artworkOptions.withGeneratedSaveUri(foundHash) + returnObj.putString("data", Uri.fromFile(File(hashedArtworkOptions.saveUri as String)).toString()) + + // If hash isn't known, save the image. + if (foundHash !in artworkOptions.knownHashes) { + val imgUri = reader.saveImage(foundImage as ByteArray, hashedArtworkOptions) + // If we failed to save the image, return `null` instead. + if (imgUri == null) { + promise.resolve(null) + return@safeExecuteOnURI } } + + // A "fixed" result for this case. + promise.resolve(returnObj) } } diff --git a/src/NativeMetadataRetriever.ts b/src/NativeMetadataRetriever.ts index a026b9d..dd47f94 100644 --- a/src/NativeMetadataRetriever.ts +++ b/src/NativeMetadataRetriever.ts @@ -9,7 +9,10 @@ import { TurboModuleRegistry } from 'react-native'; */ /** Extra options for when using `getArtwork`. */ -type ArtworkOptions = { +type MergedArtworkOptions = { + /** Specify that we want to return a base64 string representing the image. */ + base64?: boolean; + /** * A value in the range `0.0` - `1.0` specifying the quality of the resulting image. * - Defaults to `1`. @@ -20,29 +23,21 @@ type ArtworkOptions = { * - Defaults to `SaveFormat.JPEG`. */ format?: 'jpeg' | 'png' | 'webp'; + /** Uri we want to save the artwork to instead of the cache directory. */ saveUri?: string; -}; -/** Extra options for when using `getHashedArtwork`. */ -type HashedArtworkOptions = { - /** - * A value in the range `0.0` - `1.0` specifying the quality of the resulting image. - * - Defaults to `1`. - */ - compress?: number; - /** - * Specifies the format the image will be saved in. - * - Defaults to `SaveFormat.JPEG`. - */ - format?: 'jpeg' | 'png' | 'webp'; + // ------------------------------------------------------------------- + // Use hashing strategy if the following fields are provided: + // ------------------------------------------------------------------- + /** Directory where we want to save the hashed image. The file name will be its hash. */ - saveDirectory: string; + saveDirectory?: string; /** * An array of known MD5 hashes formatted as a 32-character hexadecimal string * which are stored in `saveDirectory`. */ - knownHashes: string[]; + knownHashes?: string[]; }; /** Options that can be set to modify the behavior of the package. */ @@ -78,12 +73,8 @@ export interface Spec extends TurboModule { getArtwork( uri: string, - options: ArtworkOptions & { base64?: boolean } - ): Promise; - getHashedArtwork( - uri: string, - options: HashedArtworkOptions - ): Promise<{ hash: string; uri: string } | null>; + options: MergedArtworkOptions + ): Promise<{ hash: string; data: string } | null>; getLyric(uri: string): Promise; diff --git a/src/index.ts b/src/index.ts index a655661..575917d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,7 +51,8 @@ export async function getMetadata( * - Defaults to returning up to `5 MB` of data. */ export async function getArtwork(uri: string): Promise { - return MetadataRetriever.getArtwork(uri, { base64: true }); + const result = await MetadataRetriever.getArtwork(uri, { base64: true }); + return result ? result.data : null; } /** @@ -62,7 +63,8 @@ export async function saveArtwork( uri: string, options?: ArtworkOptions ): Promise { - return MetadataRetriever.getArtwork(uri, options ?? {}); + const result = await MetadataRetriever.getArtwork(uri, options ?? {}); + return result ? result.data : null; } /** @@ -74,7 +76,17 @@ export async function saveHashedArtwork( uri: string, options: HashedArtworkOptions ): Promise<{ hash: string; uri: string } | null> { - return MetadataRetriever.getHashedArtwork(uri, options); + if (!options.saveDirectory) + throw new Error( + '`saveDirectory` is a required argument in `saveHashedArtwork`.' + ); + if (!options.knownHashes) + throw new Error( + '`knownHashes` is a required argument in `saveHashedArtwork`.' + ); + + const result = await MetadataRetriever.getArtwork(uri, options); + return result ? { hash: result.hash, uri: result.data } : null; } //#endregion From d28349931d9f6f436328e9cb37413de59c2d5b08 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:22:23 -0400 Subject: [PATCH 08/18] chore: Optimize "Save Artwork" example by using a for-loop instead of doing everything at once in a `Promise.allSettled` - Went from taking `~83s` to `~30s` for getting all artwork + saving the images to the device. - Weirdly enough, if we save in WEBP, both methods take the same amount of time (~84s). --- example/src/data/useTracksWithSavedArtwork.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/example/src/data/useTracksWithSavedArtwork.ts b/example/src/data/useTracksWithSavedArtwork.ts index 21ddd56..b2a36e8 100644 --- a/example/src/data/useTracksWithSavedArtwork.ts +++ b/example/src/data/useTracksWithSavedArtwork.ts @@ -7,8 +7,6 @@ import { useQuery } from '@tanstack/react-query'; import { getAudioFiles } from './getAudioFiles'; -import { isFulfilled } from '../utils/promise'; - export function useTracksWithSavedArtwork(hasPermissions: boolean) { return useQuery({ queryKey: ['tracks', 'savedArtwork'], @@ -37,13 +35,23 @@ async function getTracksWithSavedArtwork() { audioFiles.map(({ uri }) => uri), MetadataPresets.standard ); - const tracksMetadata = await Promise.allSettled( - results.results.map(async ({ uri, data }) => { + + const tracksMetadata: Array< + (typeof results)['results'][number]['data'] & { + id: string; + filename: string; + artworkData: string | null; + } + > = []; + + for (const { uri, data } of results.results) { + try { const { id, filename } = assetURIMap[uri]!; const imgUri = await saveArtwork(uri, { compress: 0.8 }); - return { id, filename, artworkData: imgUri, ...data }; - }) - ); + tracksMetadata.push({ id, filename, artworkData: imgUri, ...data }); + } catch {} + } + console.log( `Got metadata of ${audioFiles.length} tracks in ${( (performance.now() - start) / @@ -54,6 +62,6 @@ async function getTracksWithSavedArtwork() { return { duration: ((performance.now() - start) / 1000).toFixed(4), - tracks: tracksMetadata.filter(isFulfilled).map(({ value }) => value), + tracks: tracksMetadata, }; } From b733673a97cb90786902ac930f974f2b64ad2ea2 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:28:16 -0400 Subject: [PATCH 09/18] chore: Save a bit more time using `saveHashedArtwork` - This only works with the for-loop method. - It now takes `~23s` for the 1st save. - On successive checks, it takes `~20s` (with the hashed images already existing). --- example/src/data/useTracksWithSavedArtwork.ts | 19 +++++++++++++++---- example/src/utils/fs.ts | 9 +++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 example/src/utils/fs.ts diff --git a/example/src/data/useTracksWithSavedArtwork.ts b/example/src/data/useTracksWithSavedArtwork.ts index b2a36e8..c1b873a 100644 --- a/example/src/data/useTracksWithSavedArtwork.ts +++ b/example/src/data/useTracksWithSavedArtwork.ts @@ -1,11 +1,12 @@ import { MetadataPresets, getBulkMetadata, - saveArtwork, + saveHashedArtwork, } from '@missingcore/react-native-metadata-retriever'; import { useQuery } from '@tanstack/react-query'; import { getAudioFiles } from './getAudioFiles'; +import { ImageDirectory, getImageDirectory } from '../utils/fs'; export function useTracksWithSavedArtwork(hasPermissions: boolean) { return useQuery({ @@ -36,19 +37,29 @@ async function getTracksWithSavedArtwork() { MetadataPresets.standard ); + const savedHashedImages = getImageDirectory() + .listAsRecords() + .map(({ uri }) => uri.split('/').at(-1)?.split('.')[0]) + .filter((hash) => hash !== undefined); + const tracksMetadata: Array< (typeof results)['results'][number]['data'] & { id: string; filename: string; - artworkData: string | null; + artworkData?: string | null; } > = []; for (const { uri, data } of results.results) { try { const { id, filename } = assetURIMap[uri]!; - const imgUri = await saveArtwork(uri, { compress: 0.8 }); - tracksMetadata.push({ id, filename, artworkData: imgUri, ...data }); + const img = await saveHashedArtwork(uri, { + saveDirectory: ImageDirectory, + knownHashes: savedHashedImages, + compress: 0.8, + }); + if (img?.hash) savedHashedImages.push(img.hash); + tracksMetadata.push({ id, filename, artworkData: img?.uri, ...data }); } catch {} } diff --git a/example/src/utils/fs.ts b/example/src/utils/fs.ts new file mode 100644 index 0000000..6f1a2d0 --- /dev/null +++ b/example/src/utils/fs.ts @@ -0,0 +1,9 @@ +import { Directory, Paths } from 'expo-file-system'; + +export const ImageDirectory = Paths.join(Paths.document, 'images'); + +export function getImageDirectory() { + const imgDir = new Directory(ImageDirectory); + if (!imgDir.exists) imgDir.create(); + return imgDir; +} From 9f738305e40682fac7a06cfbe4c3b805d19bc145 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:09:55 -0400 Subject: [PATCH 10/18] refactor: Expose artwork related code in new `ArtworkParser` - Dropped the config options as it's pretty useless - if the user wants to change the max image size, they can just patch the package. --- .../metadataretriever/modules/APIConfigs.kt | 21 --- .../modules/ArtworkParser.kt | 144 ++++++++++++++++++ .../modules/MetadataReader.kt | 64 +------- .../modules/MetadataRetrieverModule.kt | 96 +++--------- .../src/data/useTracksWithBase64Artwork.ts | 3 - src/NativeMetadataRetriever.ts | 11 -- src/index.ts | 9 -- src/types/ConfigOptions.ts | 8 - 8 files changed, 165 insertions(+), 191 deletions(-) delete mode 100644 android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/APIConfigs.kt create mode 100644 android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt delete mode 100644 src/types/ConfigOptions.ts diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/APIConfigs.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/APIConfigs.kt deleted file mode 100644 index eecadd3..0000000 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/APIConfigs.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.cyanchill.missingcore.metadataretriever.modules - -import android.os.Bundle -import com.cyanchill.missingcore.metadataretriever.utils.BundleUtils - -open class APIConfigs { - /** - * Supported values: - * - MAX_IMAGE_SIZE_MB: Double? - */ - protected var apiConfigs = Bundle() - - /** Partially update `apiConfigs` based on set values. */ - fun updateConfigs(options: Bundle) { - BundleUtils.putDoubleIfExists(MAX_IMAGE_SIZE_MB, options, apiConfigs) - } - - companion object { - const val MAX_IMAGE_SIZE_MB = "maxImageSizeMB" - } -} diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt new file mode 100644 index 0000000..7c8380f --- /dev/null +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt @@ -0,0 +1,144 @@ +package com.cyanchill.missingcore.metadataretriever.modules + +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.util.Base64 +import androidx.annotation.OptIn +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Metadata +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import com.cyanchill.missingcore.metadataretriever.models.ArtworkOptions +import com.cyanchill.missingcore.metadataretriever.utils.Normalization +import com.facebook.react.bridge.ReactApplicationContext +import java.io.File +import java.io.FileOutputStream +import java.net.URLConnection +import java.security.MessageDigest +import java.util.UUID + +data class ArtworkSignature( + val hash: String, + val bytes: ByteArray, +) { + companion object { + fun fromBytes(bytes: ByteArray?): ArtworkSignature? { + if (bytes == null) return null + return ArtworkSignature(bytes.toMd5Hex(), bytes) + } + } +} + +@OptIn(UnstableApi::class) +class ArtworkParser(reactContext: ReactApplicationContext) { + private val saveDirectory = "${reactContext.cacheDir.absolutePath}${File.separator}MetadataRetriever" + + /** Create `saveDirectory` if it doesn't exist. */ + init { + try { + val directory = File(saveDirectory) + if (!directory.exists()) directory.mkdirs() + } catch (e: Exception) {} + } + + //#region [Extractor] + fun extractArtwork(uri: String, metadataList: List): ArtworkSignature? { + val isFLAC = uri.endsWith(".flac") || uri.endsWith(".m4a") || uri.endsWith(".mp4") + + // We'll want to return the image designated as "Cover (front)", otherwise return first image found. + var coverImage: ArtworkSignature? = null + var backupImage: ArtworkSignature? = null + + // Fallback to `MediaMetadataRetriever` if we find nothing with `MetadataRetriever` or with + // flac/mp4/m4a files due to artwork not being parsed correctly. + // - https://github.com/MissingCore/Music/issues/432 + if (metadataList.isEmpty() || isFLAC) { + val mmrMetadata = MediaMetadataRetriever() + mmrMetadata.setDataSource(Normalization.getSafeUri(uri)) + coverImage = ArtworkSignature.fromBytes(mmrMetadata.embeddedPicture) + mmrMetadata.release() + } + + for (metadataItem in metadataList) { + val mediaMetadata = MediaMetadata.Builder() + .populateFromMetadata(metadataItem) + .build() + + when (mediaMetadata.artworkDataType) { + // "Cover (front)" Picture Type + MediaMetadata.PICTURE_TYPE_FRONT_COVER -> { + coverImage = ArtworkSignature.fromBytes(mediaMetadata.artworkData) + } + // Fallback to 1st image found. + else -> { + if (backupImage == null) { + backupImage = ArtworkSignature.fromBytes(mediaMetadata.artworkData) + } + } + } + + if (coverImage !== null) break + } + + return coverImage ?: backupImage + } + //#endregion + + //#region ["Formatters"] + fun asBase64(bytes: ByteArray): String? { + if (!isBase64Convertible(bytes)) return null + return "data:${getMimeType(bytes)};base64,${Base64.encodeToString(bytes, Base64.DEFAULT)}" + } + + fun asFile(bytes: ByteArray, options: ArtworkOptions): String? { + try { + // Generate path to save image if we didn't provide one. + val imgUri = options.saveUri ?: "$saveDirectory${File.separator}${UUID.randomUUID()}${options.format.fileExtension}" + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + FileOutputStream(imgUri).use { fos -> + bitmap.compress( + options.format.compressFormat, + (options.compress * 100).toInt(), + fos, + ) + fos.flush() + } + return Uri.fromFile(File(imgUri)).toString() + } catch (e: Exception) { + return null + } + } + //#endregion + + //#region [Helpers] + /** Determines mimetype from bytes. */ + fun getMimeType(bytes: ByteArray): String? { + return URLConnection.guessContentTypeFromStream(bytes.inputStream())?.let { + MimeTypes.normalizeMimeType(it) + } + } + + /** Determines if ByteArray can be converted as a base64 string based on our constraints. */ + fun isBase64Convertible(bytes: ByteArray?): Boolean { + if (bytes == null) return false + // Ensure the mimeType we get is defined and is for an image. + if (!MimeTypes.isImage(getMimeType(bytes))) return false + // Convert max MB to bytes. We take 3/4 of the max MB as converting a byte array to a base64 + // string causes a 33% increase in size. + val maxSizeBytes = maxImgSizeMB * 0.75 * 1024 * 1024 + return bytes.size <= maxSizeBytes + } + //#endregion + + companion object { + var maxImgSizeMB = 5.0 + } +} + +/** Get an MD5 hash as a 32-character hexadecimal string. */ +fun ByteArray.toMd5Hex(): String { + val md = MessageDigest.getInstance("MD5") + val digest = md.digest(this) + return digest.joinToString("") { "%02x".format(it) } +} diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/MetadataReader.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/MetadataReader.kt index ee9a2cc..8924544 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/MetadataReader.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/MetadataReader.kt @@ -1,37 +1,20 @@ package com.cyanchill.missingcore.metadataretriever.modules -import android.graphics.BitmapFactory import android.media.MediaMetadataRetriever -import android.net.Uri -import android.util.Base64 import androidx.annotation.OptIn import androidx.media3.common.Format -import androidx.media3.common.MimeTypes import androidx.media3.common.PercentageRating import androidx.media3.common.Rating import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi -import com.cyanchill.missingcore.metadataretriever.models.ArtworkOptions import com.facebook.react.bridge.ReactApplicationContext -import java.io.File -import java.io.FileOutputStream -import java.net.URLConnection -import java.util.UUID /** * Utilities to format & normalize sources of metadata as a map. */ @OptIn(UnstableApi::class) -class MetadataReader(reactContext: ReactApplicationContext): APIConfigs() { - private val saveDirectory = "${reactContext.cacheDir.absolutePath}${File.separator}MetadataRetriever" - - /** Create `saveDirectory` if it doesn't exist. */ - init { - try { - val directory = File(saveDirectory) - if (!directory.exists()) directory.mkdirs() - } catch (e: Exception) {} - } +class MetadataReader(reactContext: ReactApplicationContext) { + private var artwork = ArtworkParser(reactContext) /** * Relevant metadata fields found on `Format`. @@ -64,7 +47,7 @@ class MetadataReader(reactContext: ReactApplicationContext): APIConfigs() { val dataMap = hashMapOf() // Pre-compute values to put in hash map. - val artworkData = if (getArtworkData) getBase64Image(mediaMetadata.artworkData) else null + val artworkData = if (getArtworkData) mediaMetadata.artworkData?.let { artwork.asBase64(it) } else null val trackNumber = if (mediaMetadata.trackNumber == 0) null else mediaMetadata.trackNumber val year = parseYear(mediaMetadata.recordingYear) ?: parseYear(mediaMetadata.releaseYear) @@ -120,7 +103,7 @@ class MetadataReader(reactContext: ReactApplicationContext): APIConfigs() { val dataMap = hashMapOf() // Pre-compute values to put in hash map. - val artworkData = if (getArtworkData) getBase64Image(mmr.embeddedPicture) else null + val artworkData = if (getArtworkData) mmr.embeddedPicture?.let { artwork.asBase64(it) } else null val trackNumber = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER) ?.let { if (it.toIntOrNull() == 0) null else it.toIntOrNull() } val year = parseYear(mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_YEAR)) ?: run { @@ -154,45 +137,6 @@ class MetadataReader(reactContext: ReactApplicationContext): APIConfigs() { return dataMap } - //#region [Artwork Utils] - /** Returns a base64 image string from a `ByteArray`. */ - fun getBase64Image(bytes: ByteArray? = null): String? { - if (bytes == null) return null - // Determine the mimetype from bytes. - val mimeType = URLConnection.guessContentTypeFromStream(bytes.inputStream())?.let { - MimeTypes.normalizeMimeType(it) - } - // Ensure the mimeType we get is defined and is for an image. - if (!MimeTypes.isImage(mimeType)) return null - // Convert max MB to bytes. We take 3/4 of the max MB as converting a byte array to a base64 - // string causes a 33% increase in size. - val maxSizeMB = apiConfigs.getDouble(MAX_IMAGE_SIZE_MB, 5.0) - val maxSizeBytes = maxSizeMB * 0.75 * 1024 * 1024 - if (bytes.size > maxSizeBytes) return null - return "data:$mimeType;base64,${Base64.encodeToString(bytes, Base64.DEFAULT)}" - } - - /** Save `ByteArray` as image, returning the URI if it was saved correctly. */ - fun saveImage(bytes: ByteArray, options: ArtworkOptions): String? { - try { - // Generate path to save image if we didn't provide one. - val imgUri = options.saveUri ?: "$saveDirectory${File.separator}${UUID.randomUUID()}${options.format.fileExtension}" - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) - FileOutputStream(imgUri).use { fos -> - bitmap.compress( - options.format.compressFormat, - (options.compress * 100).toInt(), - fos, - ) - fos.flush() - } - return Uri.fromFile(File(imgUri)).toString() - } catch (e: Exception) { - return null - } - } - //#endregion - //#region [Internal Helpers To Parse Metadata Values] /** * Return `null` if we see `Format.NO_VALUE` (-1). 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 237520a..245ff10 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 @@ -2,7 +2,6 @@ package com.cyanchill.missingcore.metadataretriever.modules import android.media.MediaMetadataRetriever import android.net.Uri -import android.os.Bundle import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.Format @@ -32,6 +31,7 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : private val context = reactContext private var reader = MetadataReader(reactContext) + private var artwork = ArtworkParser(reactContext) override fun getBulkMetadata(uris: ReadableArray, options: ReadableArray, promise: Promise) { val uriList = Arguments.toList(uris) as List @@ -172,94 +172,38 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : 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. - // Images are typed as `String?` if returning as base64, `ByteArray?` otherwise. - var coverImage: Any? = null - var coverImageHash: String? = null - var backupImage: Any? = null - var backupImageHash: String? = null - - val isFLAC = uri.endsWith(".flac") || uri.endsWith(".m4a") || uri.endsWith(".mp4") - - // Fallback to `MediaMetadataRetriever` if we find nothing with `MetadataRetriever` or with - // flac/mp4/m4a files due to artwork not being parsed correctly. - // - https://github.com/MissingCore/Music/issues/432 - if (metadataList.isEmpty() || isFLAC) { - val mmrMetadata = MediaMetadataRetriever() - mmrMetadata.setDataSource(Normalization.getSafeUri(uri)) - coverImage = if (asBase64) reader.getBase64Image(mmrMetadata.embeddedPicture) else mmrMetadata.embeddedPicture - coverImageHash = mmrMetadata.embeddedPicture?.toMd5Hex() - mmrMetadata.release() - } - - for (metadataItem in metadataList) { - val mediaMetadata = MediaMetadata.Builder() - .populateFromMetadata(metadataItem) - .build() - - when (mediaMetadata.artworkDataType) { - // "Cover (front)" Picture Type - MediaMetadata.PICTURE_TYPE_FRONT_COVER -> { - coverImage = if (asBase64) reader.getBase64Image(mediaMetadata.artworkData) else mediaMetadata.artworkData - coverImageHash = mediaMetadata.artworkData?.toMd5Hex() - } - // Fallback to 1st image found. - else -> { - if (backupImage == null) { - backupImage = if (asBase64) reader.getBase64Image(mediaMetadata.artworkData) else mediaMetadata.artworkData - backupImageHash = mediaMetadata.artworkData?.toMd5Hex() - } - } - } - - if (coverImage !== null) break - } - - val foundImage = coverImage ?: backupImage - val foundHash = coverImageHash ?: backupImageHash - - if (foundImage == null || foundHash == null) { - // Either both values are defined or not defined. - promise.resolve(null) - return@safeExecuteOnURI - } + val (hash, bytes) = artwork.extractArtwork(uri, metadataList) + ?: return@safeExecuteOnURI promise.resolve(null) val returnObj = Arguments.createMap().apply { - putString("hash", foundHash) + putString("hash", hash) } // Case 1: Return base64 image. if (asBase64) { - returnObj.putString("data", foundImage as String) - promise.resolve(returnObj) - return@safeExecuteOnURI + val base64Str = artwork.asBase64(bytes) + ?: return@safeExecuteOnURI promise.resolve(null) + returnObj.putString("data", base64Str) + return@safeExecuteOnURI promise.resolve(returnObj) } // Case 2: Return image independently of hash. if (artworkOptions.saveDirectory == null || artworkOptions.knownHashes == null) { - val imgUri = reader.saveImage(foundImage as ByteArray, artworkOptions) - if (imgUri == null) { - promise.resolve(null) - } else { - returnObj.putString("data", imgUri) - promise.resolve(returnObj) - } - return@safeExecuteOnURI + val imgUri = artwork.asFile(bytes, artworkOptions) + ?: return@safeExecuteOnURI promise.resolve(null) + returnObj.putString("data", imgUri) + return@safeExecuteOnURI promise.resolve(returnObj) } // Case 3: Return image with respect to hash. - val hashedArtworkOptions = artworkOptions.withGeneratedSaveUri(foundHash) - returnObj.putString("data", Uri.fromFile(File(hashedArtworkOptions.saveUri as String)).toString()) + val hashedArtworkOptions = artworkOptions.withGeneratedSaveUri(hash) + returnObj.putString("data", Uri.fromFile(File(hashedArtworkOptions.saveUri as String)).toString()) // If hash isn't known, save the image. - if (foundHash !in artworkOptions.knownHashes) { - val imgUri = reader.saveImage(foundImage as ByteArray, hashedArtworkOptions) + if (hash !in artworkOptions.knownHashes) { // If we failed to save the image, return `null` instead. - if (imgUri == null) { - promise.resolve(null) - return@safeExecuteOnURI - } + artwork.asFile(bytes, hashedArtworkOptions) + ?: return@safeExecuteOnURI promise.resolve(null) } // A "fixed" result for this case. @@ -283,12 +227,6 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : } } - /** Expose to the user the ability to update internal configuration options. */ - override fun updateConfigs(options: ReadableMap, promise: Promise) { - reader.updateConfigs(Arguments.toBundle(options) as Bundle) - promise.resolve(null) - } - override fun debugEmbeddedTags(uri: String, promise: Promise) { val returnObj = Arguments.createMap() val formatStrArr = Arguments.createArray() diff --git a/example/src/data/useTracksWithBase64Artwork.ts b/example/src/data/useTracksWithBase64Artwork.ts index c1b77c2..78e7f27 100644 --- a/example/src/data/useTracksWithBase64Artwork.ts +++ b/example/src/data/useTracksWithBase64Artwork.ts @@ -1,7 +1,6 @@ import { MetadataPresets, getBulkMetadata, - updateConfigs, } from '@missingcore/react-native-metadata-retriever'; import { useQuery } from '@tanstack/react-query'; @@ -18,8 +17,6 @@ export function useTracksWithBase64Artwork(hasPermissions: boolean) { } async function getTracksWithBase64Artwork() { - await updateConfigs({ maxImageSizeMB: 0.5 }); - const start = performance.now(); const audioFiles = await getAudioFiles(); diff --git a/src/NativeMetadataRetriever.ts b/src/NativeMetadataRetriever.ts index dd47f94..b93656a 100644 --- a/src/NativeMetadataRetriever.ts +++ b/src/NativeMetadataRetriever.ts @@ -40,15 +40,6 @@ type MergedArtworkOptions = { knownHashes?: string[]; }; -/** Options that can be set to modify the behavior of the package. */ -type ConfigOptions = { - /** - * Size of the returned base64 image in MB. - * - Defaults to `5`. - */ - maxImageSizeMB?: number | null; -}; - type DebugInfo = { /** * Result of `Format.toString()`. @@ -80,8 +71,6 @@ export interface Spec extends TurboModule { getR128Gain(uri: string): Promise; - updateConfigs(options: ConfigOptions): Promise; - /** * @deprecated For debugging purposes. Returns an object containing * the stringified `Format` & `Metadata` associated with the file. diff --git a/src/index.ts b/src/index.ts index 575917d..50d18b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,6 @@ import type { HashedArtworkOptions, } from './types/ArtworkOptions'; import { SaveFormat } from './types/ArtworkOptions'; -import type { ConfigOptions } from './types/ConfigOptions'; import type { BulkMetadata, MediaMetadataExcerpt } from './types/GetMetadata'; import type { MediaMetadata } from './types/MediaMetadata'; import type { @@ -104,13 +103,6 @@ export async function getR128Gain(uri: string): Promise { } //#endregion -//#region Configuration -/** Update internal configuration options. */ -export async function updateConfigs(options: ConfigOptions): Promise { - return MetadataRetriever.updateConfigs(options); -} -//#endregion - //#region Debug Helpers /** * @deprecated For debugging purposes. Returns an object containing @@ -124,7 +116,6 @@ export async function debugEmbeddedTags(uri: string) { export { type ArtworkOptions, type BulkMetadata, - type ConfigOptions, type HashedArtworkOptions, type MediaMetadata, type MediaMetadataExcerpt, diff --git a/src/types/ConfigOptions.ts b/src/types/ConfigOptions.ts deleted file mode 100644 index b5c56f7..0000000 --- a/src/types/ConfigOptions.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** Options that can be set to modify the behavior of the package. */ -export type ConfigOptions = { - /** - * Size of the returned base64 image in MB. - * - Defaults to `5`. - */ - maxImageSizeMB?: number | null; -}; From 37013175ec0576ef3a5d54b4acc17228fec1816c Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:26:55 -0400 Subject: [PATCH 11/18] chore: Update CHANGELOG & README --- CHANGELOG.md | 15 +++++++ README.md | 39 ++++++++++++------- .../modules/ArtworkParser.kt | 2 +- .../modules/MetadataRetrieverModule.kt | 9 ----- 4 files changed, 41 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f3e8dd..ba19572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,25 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ ## [Unreleased] +### ❗ Breaking Changes + +- Removed `updateConfigs()` function. + - If you used it to specify the max size of the return base64 image, patch the package and update the `maxImgSizeMB` variable in `ArtworkParser.kt`. + +### 🎉 Added + +- New `saveHashedArtwork()` function which saves embedded artwork by their derived MD5 hash. + - This helps reuse previously saved artwork as you can provide an array of previously saved hashes to help us identify whether the artwork should be saved. The returned `uri` is made up of the hash. + ### ⚡ Changes - Bumped AndroidX media3 to `1.10.1` from `1.9.3`. +### ⚙️ Internal Changes + +- Refactor image saving logic. +- Have the "Saved Artwork" strategy in the example app use `saveHashedArtwork()`. + ## [3.2.1] - 2026-06-21 ### 🛠️ Fixes diff --git a/README.md b/README.md index efa1ccd..77ab56e 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,7 @@ Formats that we can save the image as. function getArtwork(uri: string): Promise; ``` -Returns a base64 string representing the embedded artwork. - -> **Note:** Defaults to returning up to `5 MB` of data. Can be configured with [`updateConfigs`](#updateConfigs). +Returns a base64 string (worth up to `5 MB` of data) representing the embedded artwork. ### getBulkMetadata @@ -120,15 +118,16 @@ function saveArtwork( Returns the uri of the saved artwork. -> **Note:** Ignores the hard-limit of the max size of the image that can be saved. - -### updateConfigs +### saveHashedArtwork ```ts -function updateConfigs(options: ConfigOptions): Promise; +function saveHashedArtwork( + uri: string, + options?: HashedArtworkOptions +): Promise<{ hash: string; uri: string } | null>; ``` -Update internal configuration options such as the max size of the returned base64 image. +Returns the hash of the embedded image & uri of the saved artwork. ## Types @@ -170,19 +169,31 @@ type BulkMetadata = { Structure returned when using `getBulkMetadata`. -### ConfigOptions +### HashedArtworkOptions ```ts -type ConfigOptions = { +type HashedArtworkOptions = { + /** + * A value in the range `0.0` - `1.0` specifying the quality of the resulting image. + * - Defaults to `1`. + */ + compress?: number; + /** + * Specifies the format the image will be saved in. + * - Defaults to `SaveFormat.JPEG`. + */ + format?: SaveFormat; + /** Directory where we want to save the hashed image. The file name will be the hash. */ + saveDirectory: string; /** - * Size of the returned base64 image in MB. - * - Defaults to `5`. + * An array of known MD5 hashes formatted as a 32-character hexadecimal string + * which are stored in `saveDirectory`. */ - maxImageSizeMB?: number | null; + knownHashes: string[]; }; ``` -Configuration options we can set to modify the behavior of the package. +Options to change the behavior of `saveHashedArtwork`. ### MediaMetadata diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt index 7c8380f..7454104 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt @@ -132,7 +132,7 @@ class ArtworkParser(reactContext: ReactApplicationContext) { //#endregion companion object { - var maxImgSizeMB = 5.0 + var maxImgSizeMB: Double = 5.0 } } 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 245ff10..71902ef 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 @@ -282,15 +282,6 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : } //#endregion - //#region [Internal Overloads] - /** Get an MD5 hash as a 32-character hexadecimal string. */ - fun ByteArray.toMd5Hex(): String { - val md = MessageDigest.getInstance("MD5") - val digest = md.digest(this) - return digest.joinToString("") { "%02x".format(it) } - } - //#endregion - companion object { const val NAME = NativeMetadataRetrieverSpec.NAME } From 8cc7bf761d4c0abe3a4cebfebc5f748b9d057c3a Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:43:24 -0400 Subject: [PATCH 12/18] fix: Use a set instead of an array --- example/src/data/useTracksWithSavedArtwork.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/example/src/data/useTracksWithSavedArtwork.ts b/example/src/data/useTracksWithSavedArtwork.ts index c1b873a..3b969e5 100644 --- a/example/src/data/useTracksWithSavedArtwork.ts +++ b/example/src/data/useTracksWithSavedArtwork.ts @@ -37,10 +37,12 @@ async function getTracksWithSavedArtwork() { MetadataPresets.standard ); - const savedHashedImages = getImageDirectory() - .listAsRecords() - .map(({ uri }) => uri.split('/').at(-1)?.split('.')[0]) - .filter((hash) => hash !== undefined); + const savedHashedImages = new Set( + getImageDirectory() + .listAsRecords() + .map(({ uri }) => uri.split('/').at(-1)?.split('.')[0]) + .filter((hash) => hash !== undefined) + ); const tracksMetadata: Array< (typeof results)['results'][number]['data'] & { @@ -55,10 +57,10 @@ async function getTracksWithSavedArtwork() { const { id, filename } = assetURIMap[uri]!; const img = await saveHashedArtwork(uri, { saveDirectory: ImageDirectory, - knownHashes: savedHashedImages, + knownHashes: Array.from(savedHashedImages), compress: 0.8, }); - if (img?.hash) savedHashedImages.push(img.hash); + if (img?.hash) savedHashedImages.add(img.hash); tracksMetadata.push({ id, filename, artworkData: img?.uri, ...data }); } catch {} } From f2c526bd40a6d1b56258c991df9f3b66e3ca66e1 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:50:02 -0400 Subject: [PATCH 13/18] chore: Make URI santization util reusable --- .../metadataretriever/modules/ArtworkParser.kt | 11 ++++++++--- .../modules/MetadataRetrieverModule.kt | 5 +---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt index 7454104..da0f65c 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt @@ -104,23 +104,28 @@ class ArtworkParser(reactContext: ReactApplicationContext) { ) fos.flush() } - return Uri.fromFile(File(imgUri)).toString() + return formatUri(imgUri) } catch (e: Exception) { return null } } + + /** Sanitizes URI with Android's URI util. */ + fun formatUri(uri: String): String { + return Uri.fromFile(File(uri)).toString() + } //#endregion //#region [Helpers] /** Determines mimetype from bytes. */ - fun getMimeType(bytes: ByteArray): String? { + private fun getMimeType(bytes: ByteArray): String? { return URLConnection.guessContentTypeFromStream(bytes.inputStream())?.let { MimeTypes.normalizeMimeType(it) } } /** Determines if ByteArray can be converted as a base64 string based on our constraints. */ - fun isBase64Convertible(bytes: ByteArray?): Boolean { + private fun isBase64Convertible(bytes: ByteArray?): Boolean { if (bytes == null) return false // Ensure the mimeType we get is defined and is for an image. if (!MimeTypes.isImage(getMimeType(bytes))) return false 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 71902ef..857e0b6 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 @@ -1,7 +1,6 @@ package com.cyanchill.missingcore.metadataretriever.modules import android.media.MediaMetadataRetriever -import android.net.Uri import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.Format @@ -21,8 +20,6 @@ import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap -import java.io.File -import java.security.MessageDigest import java.util.concurrent.ExecutionException @OptIn(UnstableApi::class) @@ -197,7 +194,7 @@ class MetadataRetrieverModule(reactContext: ReactApplicationContext) : // Case 3: Return image with respect to hash. val hashedArtworkOptions = artworkOptions.withGeneratedSaveUri(hash) - returnObj.putString("data", Uri.fromFile(File(hashedArtworkOptions.saveUri as String)).toString()) + returnObj.putString("data", artwork.formatUri(hashedArtworkOptions.saveUri as String)) // If hash isn't known, save the image. if (hash !in artworkOptions.knownHashes) { From a717809dc5af5292a82407809a9eb399f9355b39 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 17:54:29 -0400 Subject: [PATCH 14/18] fix: TypeScript error (we expected `string | null`) --- example/src/data/useTracksWithSavedArtwork.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/example/src/data/useTracksWithSavedArtwork.ts b/example/src/data/useTracksWithSavedArtwork.ts index 3b969e5..3c3fde3 100644 --- a/example/src/data/useTracksWithSavedArtwork.ts +++ b/example/src/data/useTracksWithSavedArtwork.ts @@ -48,7 +48,7 @@ async function getTracksWithSavedArtwork() { (typeof results)['results'][number]['data'] & { id: string; filename: string; - artworkData?: string | null; + artworkData: string | null; } > = []; @@ -61,7 +61,12 @@ async function getTracksWithSavedArtwork() { compress: 0.8, }); if (img?.hash) savedHashedImages.add(img.hash); - tracksMetadata.push({ id, filename, artworkData: img?.uri, ...data }); + tracksMetadata.push({ + id, + filename, + artworkData: img?.uri || null, + ...data, + }); } catch {} } From bfc9d4016a85b9c2083edca897f66509f6f93566 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:07:55 -0400 Subject: [PATCH 15/18] chore: Resolve some CodeRabbit issues - Clamp `compress` option in Kotlin. - Ensure `MediaMetadataRetriever()` is released. - Use `NO_WRAP` instead of `DEFAULT` when converting ByteArray to base64. --- .../metadataretriever/models/ArtworkOptions.kt | 4 +++- .../metadataretriever/modules/ArtworkParser.kt | 11 +++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt index fb4a146..17ae15f 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt @@ -46,7 +46,9 @@ data class ArtworkOptions( return ArtworkOptions( optionsBundle.getBoolean("base64"), - if (optionsBundle.containsKey("compress")) optionsBundle.getDouble("compress") else 1.0, + if (optionsBundle.containsKey("compress")) { + optionsBundle.getDouble("compress").coerceIn(0.0, 1.0) + } else 1.0, ImageFormat.fromCode(optionsBundle.getString("format")) ?: ImageFormat.JPEG, optionsBundle.getString("saveUri")?.let { Uri.parse(it).path }, optionsBundle.getString("saveDirectory")?.let { Uri.parse(it).path }, diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt index da0f65c..4b18fa6 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt @@ -55,9 +55,12 @@ class ArtworkParser(reactContext: ReactApplicationContext) { // - https://github.com/MissingCore/Music/issues/432 if (metadataList.isEmpty() || isFLAC) { val mmrMetadata = MediaMetadataRetriever() - mmrMetadata.setDataSource(Normalization.getSafeUri(uri)) - coverImage = ArtworkSignature.fromBytes(mmrMetadata.embeddedPicture) - mmrMetadata.release() + try { + mmrMetadata.setDataSource(Normalization.getSafeUri(uri)) + coverImage = ArtworkSignature.fromBytes(mmrMetadata.embeddedPicture) + } finally { + mmrMetadata.release() + } } for (metadataItem in metadataList) { @@ -88,7 +91,7 @@ class ArtworkParser(reactContext: ReactApplicationContext) { //#region ["Formatters"] fun asBase64(bytes: ByteArray): String? { if (!isBase64Convertible(bytes)) return null - return "data:${getMimeType(bytes)};base64,${Base64.encodeToString(bytes, Base64.DEFAULT)}" + return "data:${getMimeType(bytes)};base64,${Base64.encodeToString(bytes, Base64.NO_WRAP)}" } fun asFile(bytes: ByteArray, options: ArtworkOptions): String? { From 076e2f0807aad21e73da9a235ec41ac9378b98d6 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:22:49 -0400 Subject: [PATCH 16/18] chore: Add note that `saveHashedArtwork` basically works off of a "social contract" --- README.md | 5 ++++- .../missingcore/metadataretriever/models/ArtworkOptions.kt | 2 +- src/NativeMetadataRetriever.ts | 3 ++- src/types/ArtworkOptions.ts | 3 ++- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 77ab56e..501487b 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ function saveHashedArtwork( Returns the hash of the embedded image & uri of the saved artwork. +> **Note:** It will work as "expected" given that everything follows the same "social contract", as in `knownHashes` contain hashes based on the same configuration passed to this function call. + ## Types ### ArtworkOptions @@ -187,7 +189,8 @@ type HashedArtworkOptions = { saveDirectory: string; /** * An array of known MD5 hashes formatted as a 32-character hexadecimal string - * which are stored in `saveDirectory`. + * which are stored in `saveDirectory` and is of the same format as what we + * pass for the `format` option. */ knownHashes: string[]; }; diff --git a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt index 17ae15f..46b10cb 100644 --- a/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt @@ -27,7 +27,7 @@ data class ArtworkOptions( val saveDirectory: String?, /** * [Only for Image Hashing Strategy] - * A list of image hashes inside of `saveDirectory`. + * A list of image hashes inside of `saveDirectory` with the format specified by `format`. */ val knownHashes: ArrayList? ) { diff --git a/src/NativeMetadataRetriever.ts b/src/NativeMetadataRetriever.ts index b93656a..7527000 100644 --- a/src/NativeMetadataRetriever.ts +++ b/src/NativeMetadataRetriever.ts @@ -35,7 +35,8 @@ type MergedArtworkOptions = { saveDirectory?: string; /** * An array of known MD5 hashes formatted as a 32-character hexadecimal string - * which are stored in `saveDirectory`. + * which are stored in `saveDirectory` and is of the same format as what we + * pass for the `format` option. */ knownHashes?: string[]; }; diff --git a/src/types/ArtworkOptions.ts b/src/types/ArtworkOptions.ts index e6eaac6..cd68e58 100644 --- a/src/types/ArtworkOptions.ts +++ b/src/types/ArtworkOptions.ts @@ -33,7 +33,8 @@ export type HashedArtworkOptions = SharedArtworkOptions & { saveDirectory: string; /** * An array of known MD5 hashes formatted as a 32-character hexadecimal string - * which are stored in `saveDirectory`. + * which are stored in `saveDirectory` and is of the same format as what we + * pass for the `format` option. */ knownHashes: string[]; }; From b1c01a249211452b526019d155a961c9c1fb53ae Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:27:58 -0400 Subject: [PATCH 17/18] fix: Exclude directories in `savedHashedImages` - Although, there shouldn't be any directories in our image folder. --- example/src/data/useTracksWithSavedArtwork.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/example/src/data/useTracksWithSavedArtwork.ts b/example/src/data/useTracksWithSavedArtwork.ts index 3c3fde3..6cabeac 100644 --- a/example/src/data/useTracksWithSavedArtwork.ts +++ b/example/src/data/useTracksWithSavedArtwork.ts @@ -40,7 +40,9 @@ async function getTracksWithSavedArtwork() { const savedHashedImages = new Set( getImageDirectory() .listAsRecords() - .map(({ uri }) => uri.split('/').at(-1)?.split('.')[0]) + .map(({ uri, isDirectory }) => + isDirectory ? undefined : uri.split('/').at(-1)?.split('.')[0] + ) .filter((hash) => hash !== undefined) ); From 5952d5c52579dcde3c11d43daa2251a3856638c9 Mon Sep 17 00:00:00 2001 From: cyanChill <83375816+cyanChill@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:31:34 -0400 Subject: [PATCH 18/18] chore: More fixes --- CHANGELOG.md | 1 + example/src/data/useTracksWithSavedArtwork.ts | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba19572..d5b93cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/ - Removed `updateConfigs()` function. - If you used it to specify the max size of the return base64 image, patch the package and update the `maxImgSizeMB` variable in `ArtworkParser.kt`. + - ❗ Things are a bit "strict" as the hashes in `knownHashes` must reflect the other specified arguments (`saveDirectory` & `format`). ### 🎉 Added diff --git a/example/src/data/useTracksWithSavedArtwork.ts b/example/src/data/useTracksWithSavedArtwork.ts index 6cabeac..0897e8a 100644 --- a/example/src/data/useTracksWithSavedArtwork.ts +++ b/example/src/data/useTracksWithSavedArtwork.ts @@ -55,21 +55,22 @@ async function getTracksWithSavedArtwork() { > = []; for (const { uri, data } of results.results) { + const { id, filename } = assetURIMap[uri]!; + let img: { hash: string; uri: string } | null = null; try { - const { id, filename } = assetURIMap[uri]!; - const img = await saveHashedArtwork(uri, { + img = await saveHashedArtwork(uri, { saveDirectory: ImageDirectory, knownHashes: Array.from(savedHashedImages), compress: 0.8, }); - if (img?.hash) savedHashedImages.add(img.hash); - tracksMetadata.push({ - id, - filename, - artworkData: img?.uri || null, - ...data, - }); } catch {} + if (img?.hash) savedHashedImages.add(img.hash); + tracksMetadata.push({ + id, + filename, + artworkData: img?.uri || null, + ...data, + }); } console.log(