From 88508c806a8413b61d025bc4f9c8c44fb483e916 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:57:59 +0700 Subject: [PATCH] perf(library): reuse bounded artwork thumbnails during scans --- .../zarz/spotiflac/LibraryScanStressTest.kt | 98 +++++++++++++ rust_backend/crates/core/src/cover.rs | 3 + rust_backend/crates/core/src/cover/library.rs | 137 ++++++++++++++++++ .../crates/extensions/src/backend/cover.rs | 13 +- 4 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/zarz/spotiflac/LibraryScanStressTest.kt create mode 100644 rust_backend/crates/core/src/cover/library.rs diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/LibraryScanStressTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/LibraryScanStressTest.kt new file mode 100644 index 00000000..911cf649 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/LibraryScanStressTest.kt @@ -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()) + root.deleteRecursively() + } + } +} diff --git a/rust_backend/crates/core/src/cover.rs b/rust_backend/crates/core/src/cover.rs index 9a92ba28..804f9617 100644 --- a/rust_backend/crates/core/src/cover.rs +++ b/rust_backend/crates/core/src/cover.rs @@ -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; diff --git a/rust_backend/crates/core/src/cover/library.rs b/rust_backend/crates/core/src/cover/library.rs new file mode 100644 index 00000000..4e3d5f9e --- /dev/null +++ b/rust_backend/crates/core/src/cover/library.rs @@ -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> = OnceLock::new(); + +struct Entry { + hash: u64, + original: Arc<[u8]>, + thumbnail: Arc<[u8]>, + bytes: usize, +} + +#[derive(Default)] +struct Cache { + entries: VecDeque, + 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, 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); + } +} diff --git a/rust_backend/crates/extensions/src/backend/cover.rs b/rust_backend/crates/extensions/src/backend/cover.rs index 800f2b00..b618dc41 100644 --- a/rust_backend/crates/extensions/src/backend/cover.rs +++ b/rust_backend/crates/extensions/src/backend/cover.rs @@ -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(()) }