perf(library): reuse bounded artwork thumbnails during scans

This commit is contained in:
zarzet committed 2026-09-30 20:57:59 +07:00
1 parent 6c22a91498
commit 88508c806a
4 files changed
+244 -7

No files matched your search

@@ -0,0 +1,98 @@
package com.zarz.spotiflac
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Debug
import android.os.SystemClock
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
import kotlin.concurrent.thread
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LibraryScanStressTest {
@Test
fun scansTwoThousandTracksWithLargeEmbeddedArtwork() {
assumeTrue(InstrumentationRegistry.getArguments().getString("library_stress") == "true")
val context = InstrumentationRegistry.getInstrumentation().targetContext
val root = File(context.cacheDir, "scan-stress-${System.nanoTime()}").apply { mkdirs() }
val backend = createCoreBackend(context)
val running = AtomicBoolean(true)
val peakNative = AtomicLong(0)
val peakPss = AtomicLong(0)
val sampler = thread(name = "scan-memory-sampler") {
while (running.get()) {
peakNative.updateAndGet { maxOf(it, Debug.getNativeHeapAllocatedSize()) }
peakPss.updateAndGet { maxOf(it, Debug.getPss()) }
Thread.sleep(100)
}
}
try {
backend.invokeApplication("initExtensionSystem", mapOf(
"extensions_dir" to File(root, "sources").apply { mkdirs() }.path,
"data_dir" to File(root, "data").apply { mkdirs() }.path,
"master_key" to "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
))
val tracks = File(root, "tracks").apply { mkdirs() }
val covers = File(root, "covers").apply { mkdirs() }
backend.setLibraryCoverCacheDirectory(covers.path)
val image = File(root, "cover.png")
val bitmap = Bitmap.createBitmap(2400, 2400, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(Color.rgb(45, 100, 160))
image.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 90, it) }
bitmap.recycle()
val template = File(root, "template.flac")
val fixture = NativeDownloadFinalizer.runFFmpegArguments(arrayOf(
"-v", "error", "-f", "lavfi", "-i", "sine=frequency=997:sample_rate=48000",
"-i", image.path, "-t", "0.25", "-map", "0:a", "-map", "1:v",
"-c:a", "flac", "-c:v", "copy", "-disposition:v", "attached_pic", template.path,
))
assertTrue(fixture.second, fixture.first)
val snapshot = JSONObject()
repeat(2000) { index ->
val album = File(tracks, "album-${index / 20}").apply { mkdirs() }
val file = template.copyTo(File(album, "track-$index.flac"))
snapshot.put(file.canonicalPath, file.lastModified())
}
val beforeNative = Debug.getNativeHeapAllocatedSize()
peakNative.set(beforeNative)
peakPss.set(Debug.getPss())
val output = File(root, "scan.ndjson")
val start = SystemClock.elapsedRealtime()
assertEquals(2000L, backend.scanLibraryFolderToNdjsonFile(tracks.path, output.path))
val fullMs = SystemClock.elapsedRealtime() - start
assertEquals(2000, output.useLines { it.count() })
assertEquals(0, JSONObject(backend.getLibraryScanProgress()).getInt("error_count"))
val firstNative = Debug.getNativeHeapAllocatedSize()
val repeatStart = SystemClock.elapsedRealtime()
assertEquals(2000L, backend.scanLibraryFolderToNdjsonFile(tracks.path, output.path))
val repeatMs = SystemClock.elapsedRealtime() - repeatStart
val incrementalStart = SystemClock.elapsedRealtime()
val incremental = JSONObject(backend.scanLibraryFolderIncremental(tracks.path, snapshot.toString()))
val incrementalMs = SystemClock.elapsedRealtime() - incrementalStart
assertEquals(2000, incremental.getInt("skippedCount"))
assertEquals(0, incremental.getJSONArray("scanned").length())
Log.i("LibraryScanStress", JSONObject()
.put("tracks", 2000).put("full_ms", fullMs).put("repeat_ms", repeatMs)
.put("incremental_ms", incrementalMs).put("before_native_bytes", beforeNative)
.put("after_first_native_bytes", firstNative)
.put("after_repeat_native_bytes", Debug.getNativeHeapAllocatedSize())
.put("peak_native_bytes", peakNative.get()).put("peak_pss_kib", peakPss.get())
.put("cover_files", covers.listFiles()?.size ?: 0).toString())
} finally {
running.set(false)
sampler.join()
backend.invokeApplication("cleanupExtensions", emptyMap<String, Any>())
root.deleteRecursively()
}
}
}
+3
View File
@@ -4,6 +4,9 @@ use image::{DynamicImage, ImageFormat, ImageReader};
use std::borrow::Cow;
use std::io::Cursor;
mod library;
pub use library::{clear_library_thumbnail_cache, library_thumbnail};
pub const MAX_DOWNLOAD_BYTES: usize = 24 << 20;
pub const LIBRARY_MAX_DIMENSION: i64 = 800;
const MAX_DECODE_PIXELS: u64 = 16_000_000;
@@ -0,0 +1,137 @@
//! Bounded thumbnail reuse for tracks that embed the same album artwork.
use std::collections::{VecDeque, hash_map::DefaultHasher};
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock, TryLockError};
use std::time::Duration;
const MAX_BYTES: usize = 8 << 20;
const MAX_ENTRIES: usize = 32;
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
struct Entry {
hash: u64,
original: Arc<[u8]>,
thumbnail: Arc<[u8]>,
bytes: usize,
}
#[derive(Default)]
struct Cache {
entries: VecDeque<Entry>,
bytes: usize,
}
impl Cache {
fn insert(&mut self, entry: Entry) {
if entry.bytes > MAX_BYTES {
return;
}
while self.bytes + entry.bytes > MAX_BYTES || self.entries.len() >= MAX_ENTRIES {
self.bytes -= self
.entries
.pop_front()
.expect("nonempty thumbnail cache")
.bytes;
}
self.bytes += entry.bytes;
self.entries.push_back(entry);
}
}
/// Serialize large image decodes while keeping tag reads parallel. Reuse resized
/// artwork by content, not filename or album name; never confuse alternate covers.
pub fn library_thumbnail(
data: &[u8],
check: &dyn Fn() -> Result<(), String>,
) -> Result<Arc<[u8]>, String> {
check()?;
let mut hasher = DefaultHasher::new();
data.hash(&mut hasher);
let hash = hasher.finish();
let mutex = CACHE.get_or_init(Mutex::default);
let mut cache = loop {
check()?;
match mutex.try_lock() {
Ok(cache) => break cache,
Err(TryLockError::WouldBlock) => std::thread::sleep(Duration::from_millis(10)),
Err(TryLockError::Poisoned(_)) => return Err("thumbnail cache unavailable".into()),
}
};
if let Some(index) = cache
.entries
.iter()
.position(|entry| entry.hash == hash && &*entry.original == data)
{
let entry = cache.entries.remove(index).expect("cached thumbnail");
let thumbnail = entry.thumbnail.clone();
cache.entries.push_back(entry);
return Ok(thumbnail);
}
let resized = super::resize(data, super::LIBRARY_MAX_DIMENSION, check)?;
let original: Arc<[u8]> = data.into();
let thumbnail = match resized {
std::borrow::Cow::Borrowed(_) => original.clone(),
std::borrow::Cow::Owned(data) => data.into(),
};
check()?;
let bytes = original.len()
+ if Arc::ptr_eq(&original, &thumbnail) {
0
} else {
thumbnail.len()
};
cache.insert(Entry {
hash,
original,
thumbnail: thumbnail.clone(),
bytes,
});
Ok(thumbnail)
}
pub fn clear_library_thumbnail_cache() {
if let Some(mutex) = CACHE.get()
&& let Ok(mut cache) = mutex.try_lock()
{
*cache = Cache::default();
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, ImageFormat, Rgb};
use std::io::Cursor;
#[test]
fn repeated_artwork_reuses_pixels_and_checks_cancellation_on_hits() {
let mut png = Cursor::new(Vec::new());
ImageBuffer::from_pixel(1000, 1000, Rgb([30_u8, 90, 150]))
.write_to(&mut png, ImageFormat::Png)
.unwrap();
let first = library_thumbnail(png.get_ref(), &|| Ok(())).unwrap();
let second = library_thumbnail(png.get_ref(), &|| Ok(())).unwrap();
assert!(Arc::ptr_eq(&first, &second));
assert_eq!(super::super::dimensions(&first), (800, 800));
assert!(library_thumbnail(png.get_ref(), &|| Err("cancelled".into())).is_err());
}
#[test]
fn cache_has_fixed_byte_and_entry_bounds() {
let mut cache = Cache::default();
for hash in 0..100 {
let data: Arc<[u8]> = vec![0; 1 << 20].into();
cache.insert(Entry {
hash,
original: data.clone(),
thumbnail: data,
bytes: 1 << 20,
});
assert!(cache.bytes <= MAX_BYTES);
assert!(cache.entries.len() <= MAX_ENTRIES);
}
assert_eq!(cache.bytes, MAX_BYTES);
assert_eq!(cache.entries.front().unwrap().hash, 92);
}
}
@@ -362,18 +362,16 @@ impl Backend {
));
}
let mut cover = read(&input, &format, &check)?;
if let Ok(std::borrow::Cow::Owned(resized)) = resize(
&cover.data,
spotiflac_core::cover::LIBRARY_MAX_DIMENSION,
&check,
) {
if let Ok(resized) = spotiflac_core::cover::library_thumbnail(&cover.data, &check) {
cover.mime = if resized.starts_with(b"\x89PNG") {
"image/png"
} else {
} else if resized.starts_with(b"\xff\xd8") {
"image/jpeg"
} else {
&cover.mime
}
.into();
cover.data = resized;
cover.data = resized.to_vec();
}
// Library caching may retain unsupported image bytes, but must never
// swallow cancellation while attempting the optional resize.
@@ -396,6 +394,7 @@ impl Backend {
pub fn clear_cover_memory_cache(&self) -> Result<(), String> {
let _operation = self.enter()?;
self.cover.clear();
spotiflac_core::cover::clear_library_thumbnail_cache();
Ok(())
}