diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f3e8dd..d5b93cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,26 @@ 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`. + - ❗ Things are a bit "strict" as the hashes in `knownHashes` must reflect the other specified arguments (`saveDirectory` & `format`). + +### 🎉 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..501487b 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,18 @@ 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. + +> **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 @@ -170,19 +171,32 @@ 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` and is of the same format as what we + * pass for the `format` option. */ - 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/models/ArtworkOptions.kt b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt index aaf1468..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 @@ -3,31 +3,58 @@ 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 -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") +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` with the format specified by `format`. + */ + val knownHashes: ArrayList? +) { + fun withGeneratedSaveUri(hash: String): ArtworkOptions { + if (saveDirectory == null) { + throw RequiredArgumentException("withGeneratedSaveUri", "saveDirectory") + } + return this.copy( + saveUri = "${saveDirectory}${File.separator}$hash${format.fileExtension}", + ) + } - 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 + companion object { + fun fromReadableMap(options: ReadableMap): ArtworkOptions { + val optionsBundle = Arguments.toBundle(options) as Bundle - // Remove `file://` in `saveUri` if provided. - val uri = optionsBundle.getString("saveUri") - saveUri = if (uri != null) Uri.parse(uri).path else null + return ArtworkOptions( + optionsBundle.getBoolean("base64"), + 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 }, + optionsBundle.getStringArrayList("knownHashes"), + ) + } } } 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..4b18fa6 --- /dev/null +++ b/android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/ArtworkParser.kt @@ -0,0 +1,152 @@ +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() + try { + mmrMetadata.setDataSource(Normalization.getSafeUri(uri)) + coverImage = ArtworkSignature.fromBytes(mmrMetadata.embeddedPicture) + } finally { + 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.NO_WRAP)}" + } + + 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 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. */ + 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. */ + 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 + // 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: Double = 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 b09691c..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.os.Bundle import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.Format @@ -29,6 +28,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 @@ -160,59 +160,51 @@ 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(options) - val asBase64 = artworkOptions.asBase64 + val artworkOptions = ArtworkOptions.fromReadableMap(options) + val asBase64 = artworkOptions.base64 safeExecuteOnURI(uri, "ERR_ARTWORK", promise) { val metadataList = getMetadataList(getFormatList(uri)) + val (hash, bytes) = artwork.extractArtwork(uri, metadataList) + ?: return@safeExecuteOnURI promise.resolve(null) - // We'll want to return the image designated as "Cover (front)", otherwise return first image found. - var coverImage: Any? = null - var backupImage: Any? = null + val returnObj = Arguments.createMap().apply { + putString("hash", hash) + } - val isFLAC = uri.endsWith(".flac") || uri.endsWith(".m4a") || uri.endsWith(".mp4") + // Case 1: Return base64 image. + if (asBase64) { + val base64Str = artwork.asBase64(bytes) + ?: return@safeExecuteOnURI promise.resolve(null) + returnObj.putString("data", base64Str) + return@safeExecuteOnURI promise.resolve(returnObj) + } - // 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 - mmrMetadata.release() + // Case 2: Return image independently of hash. + if (artworkOptions.saveDirectory == null || artworkOptions.knownHashes == null) { + val imgUri = artwork.asFile(bytes, artworkOptions) + ?: return@safeExecuteOnURI promise.resolve(null) + returnObj.putString("data", imgUri) + return@safeExecuteOnURI promise.resolve(returnObj) } - for (metadataItem in metadataList) { - val mediaMetadata = MediaMetadata.Builder() - .populateFromMetadata(metadataItem) - .build() + // Case 3: Return image with respect to hash. + val hashedArtworkOptions = artworkOptions.withGeneratedSaveUri(hash) + returnObj.putString("data", artwork.formatUri(hashedArtworkOptions.saveUri as String)) - when (mediaMetadata.artworkDataType) { - // "Cover (front)" Picture Type - MediaMetadata.PICTURE_TYPE_FRONT_COVER -> { - coverImage = if (asBase64) reader.getBase64Image(mediaMetadata.artworkData) else mediaMetadata.artworkData - } - // Fallback to 1st image found. - else -> { - if (backupImage == null) { - backupImage = if (asBase64) reader.getBase64Image(mediaMetadata.artworkData) else mediaMetadata.artworkData - } - } - } - - if (coverImage !== null) break + // If hash isn't known, save the image. + if (hash !in artworkOptions.knownHashes) { + // If we failed to save the image, return `null` instead. + artwork.asFile(bytes, hashedArtworkOptions) + ?: return@safeExecuteOnURI promise.resolve(null) } - 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) - } + // A "fixed" result for this case. + promise.resolve(returnObj) } } @@ -232,12 +224,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/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`.") 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/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/example/src/data/useTracksWithSavedArtwork.ts b/example/src/data/useTracksWithSavedArtwork.ts index 21ddd56..0897e8a 100644 --- a/example/src/data/useTracksWithSavedArtwork.ts +++ b/example/src/data/useTracksWithSavedArtwork.ts @@ -1,13 +1,12 @@ import { MetadataPresets, getBulkMetadata, - saveArtwork, + saveHashedArtwork, } from '@missingcore/react-native-metadata-retriever'; import { useQuery } from '@tanstack/react-query'; import { getAudioFiles } from './getAudioFiles'; - -import { isFulfilled } from '../utils/promise'; +import { ImageDirectory, getImageDirectory } from '../utils/fs'; export function useTracksWithSavedArtwork(hasPermissions: boolean) { return useQuery({ @@ -37,13 +36,43 @@ async function getTracksWithSavedArtwork() { audioFiles.map(({ uri }) => uri), MetadataPresets.standard ); - const tracksMetadata = await Promise.allSettled( - results.results.map(async ({ uri, data }) => { - const { id, filename } = assetURIMap[uri]!; - const imgUri = await saveArtwork(uri, { compress: 0.8 }); - return { id, filename, artworkData: imgUri, ...data }; - }) + + const savedHashedImages = new Set( + getImageDirectory() + .listAsRecords() + .map(({ uri, isDirectory }) => + isDirectory ? undefined : 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; + } + > = []; + + for (const { uri, data } of results.results) { + const { id, filename } = assetURIMap[uri]!; + let img: { hash: string; uri: string } | null = null; + try { + img = await saveHashedArtwork(uri, { + saveDirectory: ImageDirectory, + knownHashes: Array.from(savedHashedImages), + compress: 0.8, + }); + } catch {} + if (img?.hash) savedHashedImages.add(img.hash); + tracksMetadata.push({ + id, + filename, + artworkData: img?.uri || null, + ...data, + }); + } + console.log( `Got metadata of ${audioFiles.length} tracks in ${( (performance.now() - start) / @@ -54,6 +83,6 @@ async function getTracksWithSavedArtwork() { return { duration: ((performance.now() - start) / 1000).toFixed(4), - tracks: tracksMetadata.filter(isFulfilled).map(({ value }) => value), + tracks: tracksMetadata, }; } 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; +} 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) diff --git a/src/NativeMetadataRetriever.ts b/src/NativeMetadataRetriever.ts index 907ccc5..7527000 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,17 +23,22 @@ 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; -}; -/** Options that can be set to modify the behavior of the package. */ -type ConfigOptions = { + // ------------------------------------------------------------------- + // 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; /** - * 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` and is of the same format as what we + * pass for the `format` option. */ - maxImageSizeMB?: number | null; + knownHashes?: string[]; }; type DebugInfo = { @@ -57,15 +65,13 @@ export interface Spec extends TurboModule { getArtwork( uri: string, - options: ArtworkOptions & { base64?: boolean } - ): Promise; + options: MergedArtworkOptions + ): Promise<{ hash: string; data: string } | null>; getLyric(uri: string): Promise; 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 bb7f6fb..50d18b7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,9 +2,11 @@ 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'; import type { MediaMetadata } from './types/MediaMetadata'; import type { @@ -48,7 +50,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; } /** @@ -59,7 +62,30 @@ 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; +} + +/** + * 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> { + 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 @@ -77,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 @@ -97,7 +116,7 @@ export async function debugEmbeddedTags(uri: string) { 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..cd68e58 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,22 @@ 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 & { + /** 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` and is of the same format as what we + * pass for the `format` option. + */ + knownHashes: string[]; +}; 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; -};