mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 06:09:39 +02:00
perf(library): reuse bounded artwork thumbnails during scans
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user