Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b37f93b
chore: Initial API idea for function to identify reused artwork by th…
cyanChill Jun 28, 2026
6d09be4
chore: Functional implementation of `getHashedArtwork`
cyanChill Jun 28, 2026
ae9df3a
chore(deps): Add `expo-file-system` as a dependency for the example app
cyanChill Jun 28, 2026
c62792d
chore: Fix up description for `knownHashes`
cyanChill Jun 28, 2026
06d02d2
refactor: Export a single class to handle both iteration of getting a…
cyanChill Jun 28, 2026
a375d7d
chore: Custom exception for when a required arg is missing for the cu…
cyanChill Jun 28, 2026
a60d2c1
refactor: Have `getArtwork` from Turbo Modules support all use cases
cyanChill Jun 28, 2026
d283499
chore: Optimize "Save Artwork" example by using a for-loop instead of…
cyanChill Jun 28, 2026
b733673
chore: Save a bit more time using `saveHashedArtwork`
cyanChill Jun 28, 2026
9f73830
refactor: Expose artwork related code in new `ArtworkParser`
cyanChill Jun 28, 2026
3701317
chore: Update CHANGELOG & README
cyanChill Jun 28, 2026
8cc7bf7
fix: Use a set instead of an array
cyanChill Jun 28, 2026
f2c526b
chore: Make URI santization util reusable
cyanChill Jun 28, 2026
a717809
fix: TypeScript error (we expected `string | null`)
cyanChill Jun 28, 2026
bfc9d40
chore: Resolve some CodeRabbit issues
cyanChill Jun 28, 2026
076e2f0
chore: Add note that `saveHashedArtwork` basically works off of a "so…
cyanChill Jun 28, 2026
b1c01a2
fix: Exclude directories in `savedHashedImages`
cyanChill Jun 28, 2026
5952d5c
chore: More fixes
cyanChill Jun 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 28 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,7 @@ Formats that we can save the image as.
function getArtwork(uri: string): Promise<string | null>;
```

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

Expand Down Expand Up @@ -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<void>;
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

Expand Down Expand Up @@ -170,19 +171,32 @@ type BulkMetadata<TKeys extends MediaMetadataPublicFields> = {

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>?
) {
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"),
)
}
}
}

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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<Metadata>): 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) }
}
Loading
Loading