fix(ci): scope integration Postgres discovery to this job's network #53

Merged
bvandeusen merged 262 commits from dev into main 2026-05-18 22:50:36 -04:00
3 changed files with 206 additions and 70 deletions
Showing only changes of commit 01b05f37c3 - Show all commits
@@ -2,48 +2,47 @@ package com.fabledsword.minstrel.home.data
import com.fabledsword.minstrel.api.endpoints.HomeApi import com.fabledsword.minstrel.api.endpoints.HomeApi
import com.fabledsword.minstrel.api.endpoints.MeApi import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import com.fabledsword.minstrel.library.data.toDomain import com.fabledsword.minstrel.metadata.HomeArtistPrewarmer
import com.fabledsword.minstrel.metadata.MetadataProvider import com.fabledsword.minstrel.metadata.MetadataProvider
import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.HomeTile
import com.fabledsword.minstrel.models.SystemPlaylistsStatus import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.create import retrofit2.create
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* Cache-first reads for the Home screen. Per-section ID lists live * Cache-first reads for the Home screen. Per-section ID lists live in
* in `cached_home_index`; entity rows live in `cached_albums` / * `cached_home_index`; entity rows live in `cached_albums` /
* `cached_artists` / `cached_tracks`. * `cached_artists` / `cached_tracks`.
* *
* Sections are exposed as `Flow<List<DomainRef>>`. Each emission * Each section is a `Flow<List<HomeTile<Ref>>>`: the index Flow drives
* joins the ID list against the per-entity DAO. IDs that aren't yet * the ordered id list, and each id maps to a per-entity
* cached are silently dropped from the emitted list AND fire a * [MetadataProvider] Flow that emits the cached row (or null while it
* dedup'd background fetch via [MetadataProvider]. The fetch upserts * hydrates) and fires a dedup'd, concurrency-gated on-miss fetch. A
* into Room, the Flow re-emits, the tile populates. * null [HomeTile.value] renders as a skeleton in position; the tile
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
* per-item tile providers.
* *
* `refreshIndex()` pulls `GET /api/home/index` and replaces each * `refreshIndex()` pulls `GET /api/home/index`, replaces each section
* section in-place (delete-then-insert), so the Flow emissions * in-place (delete-then-insert, so the section Flows re-fire), and
* propagate automatically. Errors surface to the caller. No more * pre-warms the top artists via [HomeArtistPrewarmer].
* eager fan-out at refresh time — the on-miss path handles it
* declaratively as each tile materialises.
*/ */
@Singleton @Singleton
class HomeRepository @Inject constructor( class HomeRepository @Inject constructor(
private val homeIndexDao: CachedHomeIndexDao, private val homeIndexDao: CachedHomeIndexDao,
private val albumDao: CachedAlbumDao,
private val artistDao: CachedArtistDao,
private val trackDao: CachedTrackDao,
private val metadataProvider: MetadataProvider, private val metadataProvider: MetadataProvider,
private val prewarmer: HomeArtistPrewarmer,
retrofit: Retrofit, retrofit: Retrofit,
) { ) {
private val api: HomeApi = retrofit.create() private val api: HomeApi = retrofit.create()
@@ -62,26 +61,25 @@ class HomeRepository @Inject constructor(
) )
} }
fun observeRecentlyAddedAlbums(): Flow<List<AlbumRef>> = fun observeRecentlyAddedAlbums(): Flow<List<HomeTile<AlbumRef>>> =
homeIndexDao.observeBySection(SECTION_RECENTLY_ADDED_ALBUMS).map { hydrateAlbums(it) } observeAlbumSection(SECTION_RECENTLY_ADDED_ALBUMS)
fun observeRediscoverAlbums(): Flow<List<AlbumRef>> = fun observeRediscoverAlbums(): Flow<List<HomeTile<AlbumRef>>> =
homeIndexDao.observeBySection(SECTION_REDISCOVER_ALBUMS).map { hydrateAlbums(it) } observeAlbumSection(SECTION_REDISCOVER_ALBUMS)
fun observeRediscoverArtists(): Flow<List<ArtistRef>> = fun observeRediscoverArtists(): Flow<List<HomeTile<ArtistRef>>> =
homeIndexDao.observeBySection(SECTION_REDISCOVER_ARTISTS).map { hydrateArtists(it) } observeArtistSection(SECTION_REDISCOVER_ARTISTS)
fun observeMostPlayedTracks(): Flow<List<TrackRef>> = fun observeMostPlayedTracks(): Flow<List<HomeTile<TrackRef>>> =
homeIndexDao.observeBySection(SECTION_MOST_PLAYED_TRACKS).map { hydrateTracks(it) } observeTrackSection(SECTION_MOST_PLAYED_TRACKS)
fun observeLastPlayedArtists(): Flow<List<ArtistRef>> = fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
homeIndexDao.observeBySection(SECTION_LAST_PLAYED_ARTISTS).map { hydrateArtists(it) } observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
/** /**
* Pulls /api/home/index and replaces each section in * Pulls /api/home/index, replaces each cached_home_index section,
* cached_home_index. The Flow emissions re-fire automatically, * and pre-warms the top artists. The section Flows re-fire on the
* and any missing entity rows fire on-miss fetches via the * index change; missing entity rows hydrate via the on-miss path.
* hydrate helpers below.
*/ */
suspend fun refreshIndex() { suspend fun refreshIndex() {
val wire = api.getHomeIndex() val wire = api.getHomeIndex()
@@ -90,6 +88,7 @@ class HomeRepository @Inject constructor(
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists) replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
prewarmer.warm(wire.rediscoverArtists + wire.lastPlayedArtists)
} }
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) { private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
@@ -107,25 +106,40 @@ class HomeRepository @Inject constructor(
) )
} }
private suspend fun hydrateAlbums(rows: List<CachedHomeIndexEntity>): List<AlbumRef> = @OptIn(ExperimentalCoroutinesApi::class)
rows.mapNotNull { row -> private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
val cached = albumDao.getById(row.entityId) homeIndexDao.observeBySection(section).flatMapLatest { rows ->
if (cached == null) metadataProvider.refreshAlbum(row.entityId) if (rows.isEmpty()) {
cached?.toDomain() flowOf(emptyList())
} else {
combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
}
}
} }
private suspend fun hydrateArtists(rows: List<CachedHomeIndexEntity>): List<ArtistRef> = @OptIn(ExperimentalCoroutinesApi::class)
rows.mapNotNull { row -> private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
val cached = artistDao.getById(row.entityId) homeIndexDao.observeBySection(section).flatMapLatest { rows ->
if (cached == null) metadataProvider.refreshArtist(row.entityId) if (rows.isEmpty()) {
cached?.toDomain() flowOf(emptyList())
} else {
combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
}
}
} }
private suspend fun hydrateTracks(rows: List<CachedHomeIndexEntity>): List<TrackRef> = @OptIn(ExperimentalCoroutinesApi::class)
rows.mapNotNull { row -> private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
val cached = trackDao.getById(row.entityId) homeIndexDao.observeBySection(section).flatMapLatest { rows ->
if (cached == null) metadataProvider.refreshTrack(row.entityId) if (rows.isEmpty()) {
cached?.toDomain() flowOf(emptyList())
} else {
combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs ->
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
}
}
} }
companion object { companion object {
@@ -55,6 +55,7 @@ import com.fabledsword.minstrel.library.widgets.AlbumCard
import com.fabledsword.minstrel.library.widgets.ArtistCard import com.fabledsword.minstrel.library.widgets.ArtistCard
import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.HomeTile
import com.fabledsword.minstrel.models.PlaylistRef import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.SystemPlaylistsStatus import com.fabledsword.minstrel.models.SystemPlaylistsStatus
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
@@ -97,11 +98,11 @@ private const val RECENTLY_ADDED_CHUNK = 25
data class HomeSections( data class HomeSections(
val playlists: List<PlaylistRef> = emptyList(), val playlists: List<PlaylistRef> = emptyList(),
val recentlyAddedAlbums: List<AlbumRef> = emptyList(), val recentlyAddedAlbums: List<HomeTile<AlbumRef>> = emptyList(),
val rediscoverAlbums: List<AlbumRef> = emptyList(), val rediscoverAlbums: List<HomeTile<AlbumRef>> = emptyList(),
val rediscoverArtists: List<ArtistRef> = emptyList(), val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
val mostPlayedTracks: List<TrackRef> = emptyList(), val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
val lastPlayedArtists: List<ArtistRef> = emptyList(), val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
) { ) {
val isAllEmpty: Boolean val isAllEmpty: Boolean
get() = playlists.isEmpty() && get() = playlists.isEmpty() &&
@@ -386,7 +387,7 @@ private fun HomeSuccessContent(
} }
private fun LazyListScope.recentlyAddedSection( private fun LazyListScope.recentlyAddedSection(
albums: List<AlbumRef>, albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
) { ) {
if (albums.isEmpty()) { if (albums.isEmpty()) {
@@ -415,8 +416,8 @@ private fun LazyListScope.recentlyAddedSection(
} }
private fun LazyListScope.rediscoverSection( private fun LazyListScope.rediscoverSection(
albums: List<AlbumRef>, albums: List<HomeTile<AlbumRef>>,
artists: List<ArtistRef>, artists: List<HomeTile<ArtistRef>>,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit, onArtistClick: (String) -> Unit,
) { ) {
@@ -433,7 +434,7 @@ private fun LazyListScope.rediscoverSection(
} }
private fun LazyListScope.mostPlayedSection( private fun LazyListScope.mostPlayedSection(
tracks: List<TrackRef>, tracks: List<HomeTile<TrackRef>>,
onMostPlayedTap: (List<TrackRef>, Int) -> Unit, onMostPlayedTap: (List<TrackRef>, Int) -> Unit,
) { ) {
item { item {
@@ -449,7 +450,7 @@ private fun LazyListScope.mostPlayedSection(
} }
private fun LazyListScope.lastPlayedSection( private fun LazyListScope.lastPlayedSection(
artists: List<ArtistRef>, artists: List<HomeTile<ArtistRef>>,
onArtistClick: (String) -> Unit, onArtistClick: (String) -> Unit,
) { ) {
item { item {
@@ -492,8 +493,8 @@ private fun EmptySection(title: String, body: String) {
*/ */
@Composable @Composable
private fun RediscoverBlock( private fun RediscoverBlock(
albums: List<AlbumRef>, albums: List<HomeTile<AlbumRef>>,
artists: List<ArtistRef>, artists: List<HomeTile<ArtistRef>>,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
onArtistClick: (String) -> Unit, onArtistClick: (String) -> Unit,
) { ) {
@@ -511,12 +512,17 @@ private fun RediscoverBlock(
@Composable @Composable
private fun AlbumsRow( private fun AlbumsRow(
title: String, title: String,
albums: List<AlbumRef>, albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit, onAlbumClick: (String) -> Unit,
) { ) {
HorizontalScrollRow(title = title) { HorizontalScrollRow(title = title) {
items(items = albums, key = { it.id }) { album -> items(items = albums, key = { it.id }) { tile ->
AlbumCard(album = album, onClick = { onAlbumClick(album.id) }) val album = tile.value
if (album == null) {
SkeletonAlbumTile()
} else {
AlbumCard(album = album, onClick = { onAlbumClick(album.id) })
}
} }
} }
} }
@@ -611,12 +617,17 @@ private const val SONGS_LIKE_SLOTS = 3
@Composable @Composable
private fun ArtistsRow( private fun ArtistsRow(
title: String, title: String,
artists: List<ArtistRef>, artists: List<HomeTile<ArtistRef>>,
onArtistClick: (String) -> Unit, onArtistClick: (String) -> Unit,
) { ) {
HorizontalScrollRow(title = title) { HorizontalScrollRow(title = title) {
items(items = artists, key = { it.id }) { artist -> items(items = artists, key = { it.id }) { tile ->
ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) }) val artist = tile.value
if (artist == null) {
SkeletonArtistTile()
} else {
ArtistCard(artist = artist, onClick = { onArtistClick(artist.id) })
}
} }
} }
} }
@@ -629,16 +640,34 @@ private fun ArtistsRow(
*/ */
@Composable @Composable
private fun MostPlayedRow( private fun MostPlayedRow(
tracks: List<TrackRef>, tracks: List<HomeTile<TrackRef>>,
onTap: (List<TrackRef>, Int) -> Unit, onTap: (List<TrackRef>, Int) -> Unit,
) { ) {
HorizontalScrollRow(title = "Most played") { HorizontalScrollRow(title = "Most played") {
itemsIndexed(items = tracks, key = { _, t -> t.id }) { index, track -> items(items = tracks, key = { it.id }) { tile ->
CompactTrackTile(track = track, onClick = { onTap(tracks, index) }) val track = tile.value
if (track == null) {
SkeletonCompactTrackTile()
} else {
CompactTrackTile(
track = track,
onClick = {
val playable = tracks.mapNotNull { it.value }
onTap(playable, playable.indexOfFirst { it.id == track.id })
},
)
}
} }
} }
} }
@Composable
private fun SkeletonCompactTrackTile() {
Column(modifier = Modifier.width(140.dp)) {
SkeletonAlbumTile()
}
}
@Composable @Composable
private fun CompactTrackTile( private fun CompactTrackTile(
track: TrackRef, track: TrackRef,
@@ -0,0 +1,93 @@
package com.fabledsword.minstrel.home.data
import app.cash.turbine.test
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
import com.fabledsword.minstrel.metadata.HomeArtistPrewarmer
import com.fabledsword.minstrel.metadata.MetadataProvider
import com.fabledsword.minstrel.models.AlbumRef
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import retrofit2.Retrofit
import kotlin.test.assertEquals
import kotlin.test.assertNull
class HomeRepositoryTest {
private lateinit var server: MockWebServer
private lateinit var retrofit: Retrofit
private val homeIndexDao = mockk<CachedHomeIndexDao>(relaxed = true)
private val metadataProvider = mockk<MetadataProvider>(relaxed = true)
private val prewarmer = mockk<HomeArtistPrewarmer>(relaxed = true)
@BeforeEach
fun setup() {
server = MockWebServer().apply { start() }
retrofit = Retrofit.Builder()
.baseUrl(server.url("/"))
.client(OkHttpClient.Builder().build())
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
}
@AfterEach
fun teardown() {
server.shutdown()
}
private companion object {
private val json = Json { ignoreUnknownKeys = true }
}
private fun repo() = HomeRepository(homeIndexDao, metadataProvider, prewarmer, retrofit)
@Test
fun `rediscover albums emit skeleton tiles then reveal as rows land`() = runTest {
val indexRow = CachedHomeIndexEntity(
section = HomeRepository.SECTION_REDISCOVER_ALBUMS,
position = 0,
entityType = "album",
entityId = "al1",
)
every {
homeIndexDao.observeBySection(HomeRepository.SECTION_REDISCOVER_ALBUMS)
} returns flowOf(listOf(indexRow))
val albumFlow = MutableStateFlow<AlbumRef?>(null)
every { metadataProvider.observeAlbum("al1") } returns albumFlow
repo().observeRediscoverAlbums().test {
val skeleton = awaitItem()
assertEquals(1, skeleton.size)
assertEquals("al1", skeleton[0].id)
assertNull(skeleton[0].value) // skeleton in position
albumFlow.value = AlbumRef(id = "al1", title = "Geogaddi", artistId = "a1")
val revealed = awaitItem()
assertEquals("al1", revealed[0].id)
assertEquals("Geogaddi", revealed[0].value?.title) // tile reveals
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `empty section emits empty list`() = runTest {
every {
homeIndexDao.observeBySection(HomeRepository.SECTION_REDISCOVER_ALBUMS)
} returns flowOf(emptyList())
repo().observeRediscoverAlbums().test {
assertEquals(0, awaitItem().size)
awaitComplete()
}
}
}