From 4eb225914c6dfd913662b0e8948990fa0ea4ec81 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 28 May 2026 18:58:29 -0400 Subject: [PATCH] feat(android): add HomeArtistPrewarmer (top-8 artists, session-deduped) --- .../minstrel/metadata/HomeArtistPrewarmer.kt | 33 +++++++++++++++++++ .../metadata/HomeArtistPrewarmerTest.kt | 25 ++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmer.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmerTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmer.kt b/android/app/src/main/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmer.kt new file mode 100644 index 00000000..a89b39e6 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmer.kt @@ -0,0 +1,33 @@ +package com.fabledsword.minstrel.metadata + +import java.util.Collections +import javax.inject.Inject +import javax.inject.Singleton + +private const val TOP_N = 8 + +/** + * Pre-warms the Room cache for likely-tap artist tiles on Home. Mirrors + * Flutter's `MetadataPrefetcher`: conservative — artists only (a single + * round-trip per id), top-8 per call, and once per id per session so + * repeated index refreshes don't re-fetch. Albums are intentionally not + * pre-warmed (their detail fetch fans out a track list). + * + * Fetches go through [MetadataProvider.refreshArtist], so they share the + * same per-id dedup AND the 4-slot concurrency gate as on-render + * hydration — the prewarm can't starve the tiles the user is looking at. + */ +@Singleton +class HomeArtistPrewarmer @Inject constructor( + private val metadataProvider: MetadataProvider, +) { + private val warmed = Collections.synchronizedSet(mutableSetOf()) + + fun warm(artistIds: List) { + artistIds.asSequence() + .filter { it.isNotEmpty() } + .filter { warmed.add(it) } + .take(TOP_N) + .forEach { metadataProvider.refreshArtist(it) } + } +} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmerTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmerTest.kt new file mode 100644 index 00000000..4aaf8bbb --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/metadata/HomeArtistPrewarmerTest.kt @@ -0,0 +1,25 @@ +package com.fabledsword.minstrel.metadata + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +class HomeArtistPrewarmerTest { + private val provider = mockk() + + @Test + fun `warms at most the top eight distinct ids, once per session`() { + every { provider.refreshArtist(any()) } returns mockk(relaxed = true) + val prewarmer = HomeArtistPrewarmer(provider) + + // 10 ids with a duplicate and a blank -> 8 distinct non-blank warmed. + prewarmer.warm(listOf("a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "")) + // Re-warm: already-warmed ids are skipped. + prewarmer.warm(listOf("a1", "a2")) + + (1..8).forEach { i -> verify(exactly = 1) { provider.refreshArtist("a$i") } } + verify(exactly = 0) { provider.refreshArtist("a9") } // past the top-8 cap + verify(exactly = 0) { provider.refreshArtist("") } // blank skipped + } +}