Skip to content

Commit 97bd81d

Browse files
authored
feat: Support saving embedded images based on their hash (#19)
- This should reduce the amount of images being saved and make things go a bit faster. - ❗ This really only works based on a "social contract", where the hashes passed to `knownHashes` are stored in `saveDirectory` & has the same `format`. - ❗ Removed `updateConfigs()`.
1 parent b6af69e commit 97bd81d

17 files changed

Lines changed: 401 additions & 208 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,26 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
77

88
## [Unreleased]
99

10+
### ❗ Breaking Changes
11+
12+
- Removed `updateConfigs()` function.
13+
- 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`.
14+
- ❗ Things are a bit "strict" as the hashes in `knownHashes` must reflect the other specified arguments (`saveDirectory` & `format`).
15+
16+
### 🎉 Added
17+
18+
- New `saveHashedArtwork()` function which saves embedded artwork by their derived MD5 hash.
19+
- 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.
20+
1021
### ⚡ Changes
1122

1223
- Bumped AndroidX media3 to `1.10.1` from `1.9.3`.
1324

25+
### ⚙️ Internal Changes
26+
27+
- Refactor image saving logic.
28+
- Have the "Saved Artwork" strategy in the example app use `saveHashedArtwork()`.
29+
1430
## [3.2.1] - 2026-06-21
1531

1632
### 🛠️ Fixes

README.md

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,7 @@ Formats that we can save the image as.
6565
function getArtwork(uri: string): Promise<string | null>;
6666
```
6767

68-
Returns a base64 string representing the embedded artwork.
69-
70-
> **Note:** Defaults to returning up to `5 MB` of data. Can be configured with [`updateConfigs`](#updateConfigs).
68+
Returns a base64 string (worth up to `5 MB` of data) representing the embedded artwork.
7169

7270
### getBulkMetadata
7371

@@ -120,15 +118,18 @@ function saveArtwork(
120118

121119
Returns the uri of the saved artwork.
122120

123-
> **Note:** Ignores the hard-limit of the max size of the image that can be saved.
124-
125-
### updateConfigs
121+
### saveHashedArtwork
126122

127123
```ts
128-
function updateConfigs(options: ConfigOptions): Promise<void>;
124+
function saveHashedArtwork(
125+
uri: string,
126+
options?: HashedArtworkOptions
127+
): Promise<{ hash: string; uri: string } | null>;
129128
```
130129

131-
Update internal configuration options such as the max size of the returned base64 image.
130+
Returns the hash of the embedded image & uri of the saved artwork.
131+
132+
> **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.
132133
133134
## Types
134135

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

171172
Structure returned when using `getBulkMetadata`.
172173

173-
### ConfigOptions
174+
### HashedArtworkOptions
174175

175176
```ts
176-
type ConfigOptions = {
177+
type HashedArtworkOptions = {
178+
/**
179+
* A value in the range `0.0` - `1.0` specifying the quality of the resulting image.
180+
* - Defaults to `1`.
181+
*/
182+
compress?: number;
183+
/**
184+
* Specifies the format the image will be saved in.
185+
* - Defaults to `SaveFormat.JPEG`.
186+
*/
187+
format?: SaveFormat;
188+
/** Directory where we want to save the hashed image. The file name will be the hash. */
189+
saveDirectory: string;
177190
/**
178-
* Size of the returned base64 image in MB.
179-
* - Defaults to `5`.
191+
* An array of known MD5 hashes formatted as a 32-character hexadecimal string
192+
* which are stored in `saveDirectory` and is of the same format as what we
193+
* pass for the `format` option.
180194
*/
181-
maxImageSizeMB?: number | null;
195+
knownHashes: string[];
182196
};
183197
```
184198

185-
Configuration options we can set to modify the behavior of the package.
199+
Options to change the behavior of `saveHashedArtwork`.
186200

187201
### MediaMetadata
188202

android/src/main/java/com/cyanchill/missingcore/metadataretriever/models/ArtworkOptions.kt

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,31 +3,58 @@ package com.cyanchill.missingcore.metadataretriever.models
33
import android.graphics.Bitmap
44
import android.net.Uri
55
import android.os.Bundle
6+
import com.cyanchill.missingcore.metadataretriever.utils.RequiredArgumentException
67
import com.facebook.react.bridge.Arguments
78
import com.facebook.react.bridge.ReadableMap
9+
import java.io.File
810

9-
class ArtworkOptions(options: ReadableMap) {
10-
/** If we want to return the artwork as a base64 string. */
11-
val asBase64: Boolean
12-
13-
/** A value in the range `0.0` - `1.0` specifying the quality of the resulting image. */
14-
val compress: Double
15-
/** Specifies the format the image will be saved in. */
16-
val format: ImageFormat
17-
/** Location where we want to save the image. */
18-
val saveUri: String?
19-
20-
init {
21-
val optionsBundle = Arguments.toBundle(options) as Bundle
22-
asBase64 = optionsBundle.getBoolean("base64")
11+
data class ArtworkOptions(
12+
/** If the image should be returned as a base64 string. */
13+
val base64: Boolean = false,
14+
/** Value in the range of `0.0` - `1.0` specifying the quality of the resulting image. */
15+
val compress: Double = 1.0,
16+
/** Format the image will be saved in. */
17+
val format: ImageFormat = ImageFormat.JPEG,
18+
/**
19+
* [Only for Non-Image Hashing Strategy]
20+
* "Location" artwork will be saved to (includes file name + extension).
21+
*/
22+
val saveUri: String?,
23+
/**
24+
* [Only for Image Hashing Strategy]
25+
* The directory the file will be saved to.
26+
*/
27+
val saveDirectory: String?,
28+
/**
29+
* [Only for Image Hashing Strategy]
30+
* A list of image hashes inside of `saveDirectory` with the format specified by `format`.
31+
*/
32+
val knownHashes: ArrayList<String>?
33+
) {
34+
fun withGeneratedSaveUri(hash: String): ArtworkOptions {
35+
if (saveDirectory == null) {
36+
throw RequiredArgumentException("withGeneratedSaveUri", "saveDirectory")
37+
}
38+
return this.copy(
39+
saveUri = "${saveDirectory}${File.separator}$hash${format.fileExtension}",
40+
)
41+
}
2342

24-
compress = if (optionsBundle.containsKey("compress")) optionsBundle.getDouble("compress") else 1.0
25-
// Default format to JPEG if it's not provided.
26-
format = ImageFormat.fromCode(optionsBundle.getString("format")) ?: ImageFormat.JPEG
43+
companion object {
44+
fun fromReadableMap(options: ReadableMap): ArtworkOptions {
45+
val optionsBundle = Arguments.toBundle(options) as Bundle
2746

28-
// Remove `file://` in `saveUri` if provided.
29-
val uri = optionsBundle.getString("saveUri")
30-
saveUri = if (uri != null) Uri.parse(uri).path else null
47+
return ArtworkOptions(
48+
optionsBundle.getBoolean("base64"),
49+
if (optionsBundle.containsKey("compress")) {
50+
optionsBundle.getDouble("compress").coerceIn(0.0, 1.0)
51+
} else 1.0,
52+
ImageFormat.fromCode(optionsBundle.getString("format")) ?: ImageFormat.JPEG,
53+
optionsBundle.getString("saveUri")?.let { Uri.parse(it).path },
54+
optionsBundle.getString("saveDirectory")?.let { Uri.parse(it).path },
55+
optionsBundle.getStringArrayList("knownHashes"),
56+
)
57+
}
3158
}
3259
}
3360

android/src/main/java/com/cyanchill/missingcore/metadataretriever/modules/APIConfigs.kt

Lines changed: 0 additions & 21 deletions
This file was deleted.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package com.cyanchill.missingcore.metadataretriever.modules
2+
3+
import android.graphics.BitmapFactory
4+
import android.media.MediaMetadataRetriever
5+
import android.net.Uri
6+
import android.util.Base64
7+
import androidx.annotation.OptIn
8+
import androidx.media3.common.MediaMetadata
9+
import androidx.media3.common.Metadata
10+
import androidx.media3.common.MimeTypes
11+
import androidx.media3.common.util.UnstableApi
12+
import com.cyanchill.missingcore.metadataretriever.models.ArtworkOptions
13+
import com.cyanchill.missingcore.metadataretriever.utils.Normalization
14+
import com.facebook.react.bridge.ReactApplicationContext
15+
import java.io.File
16+
import java.io.FileOutputStream
17+
import java.net.URLConnection
18+
import java.security.MessageDigest
19+
import java.util.UUID
20+
21+
data class ArtworkSignature(
22+
val hash: String,
23+
val bytes: ByteArray,
24+
) {
25+
companion object {
26+
fun fromBytes(bytes: ByteArray?): ArtworkSignature? {
27+
if (bytes == null) return null
28+
return ArtworkSignature(bytes.toMd5Hex(), bytes)
29+
}
30+
}
31+
}
32+
33+
@OptIn(UnstableApi::class)
34+
class ArtworkParser(reactContext: ReactApplicationContext) {
35+
private val saveDirectory = "${reactContext.cacheDir.absolutePath}${File.separator}MetadataRetriever"
36+
37+
/** Create `saveDirectory` if it doesn't exist. */
38+
init {
39+
try {
40+
val directory = File(saveDirectory)
41+
if (!directory.exists()) directory.mkdirs()
42+
} catch (e: Exception) {}
43+
}
44+
45+
//#region [Extractor]
46+
fun extractArtwork(uri: String, metadataList: List<Metadata>): ArtworkSignature? {
47+
val isFLAC = uri.endsWith(".flac") || uri.endsWith(".m4a") || uri.endsWith(".mp4")
48+
49+
// We'll want to return the image designated as "Cover (front)", otherwise return first image found.
50+
var coverImage: ArtworkSignature? = null
51+
var backupImage: ArtworkSignature? = null
52+
53+
// Fallback to `MediaMetadataRetriever` if we find nothing with `MetadataRetriever` or with
54+
// flac/mp4/m4a files due to artwork not being parsed correctly.
55+
// - https://github.com/MissingCore/Music/issues/432
56+
if (metadataList.isEmpty() || isFLAC) {
57+
val mmrMetadata = MediaMetadataRetriever()
58+
try {
59+
mmrMetadata.setDataSource(Normalization.getSafeUri(uri))
60+
coverImage = ArtworkSignature.fromBytes(mmrMetadata.embeddedPicture)
61+
} finally {
62+
mmrMetadata.release()
63+
}
64+
}
65+
66+
for (metadataItem in metadataList) {
67+
val mediaMetadata = MediaMetadata.Builder()
68+
.populateFromMetadata(metadataItem)
69+
.build()
70+
71+
when (mediaMetadata.artworkDataType) {
72+
// "Cover (front)" Picture Type
73+
MediaMetadata.PICTURE_TYPE_FRONT_COVER -> {
74+
coverImage = ArtworkSignature.fromBytes(mediaMetadata.artworkData)
75+
}
76+
// Fallback to 1st image found.
77+
else -> {
78+
if (backupImage == null) {
79+
backupImage = ArtworkSignature.fromBytes(mediaMetadata.artworkData)
80+
}
81+
}
82+
}
83+
84+
if (coverImage !== null) break
85+
}
86+
87+
return coverImage ?: backupImage
88+
}
89+
//#endregion
90+
91+
//#region ["Formatters"]
92+
fun asBase64(bytes: ByteArray): String? {
93+
if (!isBase64Convertible(bytes)) return null
94+
return "data:${getMimeType(bytes)};base64,${Base64.encodeToString(bytes, Base64.NO_WRAP)}"
95+
}
96+
97+
fun asFile(bytes: ByteArray, options: ArtworkOptions): String? {
98+
try {
99+
// Generate path to save image if we didn't provide one.
100+
val imgUri = options.saveUri ?: "$saveDirectory${File.separator}${UUID.randomUUID()}${options.format.fileExtension}"
101+
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
102+
FileOutputStream(imgUri).use { fos ->
103+
bitmap.compress(
104+
options.format.compressFormat,
105+
(options.compress * 100).toInt(),
106+
fos,
107+
)
108+
fos.flush()
109+
}
110+
return formatUri(imgUri)
111+
} catch (e: Exception) {
112+
return null
113+
}
114+
}
115+
116+
/** Sanitizes URI with Android's URI util. */
117+
fun formatUri(uri: String): String {
118+
return Uri.fromFile(File(uri)).toString()
119+
}
120+
//#endregion
121+
122+
//#region [Helpers]
123+
/** Determines mimetype from bytes. */
124+
private fun getMimeType(bytes: ByteArray): String? {
125+
return URLConnection.guessContentTypeFromStream(bytes.inputStream())?.let {
126+
MimeTypes.normalizeMimeType(it)
127+
}
128+
}
129+
130+
/** Determines if ByteArray can be converted as a base64 string based on our constraints. */
131+
private fun isBase64Convertible(bytes: ByteArray?): Boolean {
132+
if (bytes == null) return false
133+
// Ensure the mimeType we get is defined and is for an image.
134+
if (!MimeTypes.isImage(getMimeType(bytes))) return false
135+
// Convert max MB to bytes. We take 3/4 of the max MB as converting a byte array to a base64
136+
// string causes a 33% increase in size.
137+
val maxSizeBytes = maxImgSizeMB * 0.75 * 1024 * 1024
138+
return bytes.size <= maxSizeBytes
139+
}
140+
//#endregion
141+
142+
companion object {
143+
var maxImgSizeMB: Double = 5.0
144+
}
145+
}
146+
147+
/** Get an MD5 hash as a 32-character hexadecimal string. */
148+
fun ByteArray.toMd5Hex(): String {
149+
val md = MessageDigest.getInstance("MD5")
150+
val digest = md.digest(this)
151+
return digest.joinToString("") { "%02x".format(it) }
152+
}

0 commit comments

Comments
 (0)