feat(android): LibraryRepository + domain types + mappers (M8 phase 5.2)
Cache-first reads of artists/albums/tracks. The Room DAOs are the source
of truth ViewModels observe; refreshArtistDetail / refreshAlbumDetail
pull from the server and upsert into Room — Flow emissions propagate
automatically.
- models/TrackRef.kt, models/ArtistRef.kt, models/AlbumRef.kt — domain
types mirroring flutter_client/lib/models/. `Ref` suffix matches
Flutter convention (lightweight reference, not full per-row metadata).
- library/data/LibraryMappers.kt — wire->entity (for sync writes),
entity->domain (for cache reads in ViewModels), wire->domain (for
fresh server responses bypassing cache), detail-wire->entity (drops
embedded array, repository upserts those separately).
- library/data/LibraryRepository.kt — Hilt-injected, observe* Flow
methods + suspend refresh* methods that upsert through the relevant
DAOs. Constructs its own LibraryApi via `retrofit.create()` per the
"repos own their interfaces" pattern adopted in NetworkModule.
- LibraryRepositoryTest.kt — MockK + Turbine + MockWebServer.
Verifies the Flow mapping, the wire->entity upsert split, and the
null-on-miss case for getArtist.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,139 @@
|
|||||||
|
package com.fabledsword.minstrel.library.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
|
||||||
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.AlbumWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.ArtistWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||||
|
|
||||||
|
// Wire to Entity — used after a successful sync/fetch to persist into Room.
|
||||||
|
// Entity fields are a subset of wire fields (display names + sort keys);
|
||||||
|
// fields the entity doesn't store are simply dropped.
|
||||||
|
|
||||||
|
fun ArtistWire.toEntity(): CachedArtistEntity =
|
||||||
|
CachedArtistEntity(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
sortName = sortName,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun AlbumWire.toEntity(): CachedAlbumEntity =
|
||||||
|
CachedAlbumEntity(
|
||||||
|
id = id,
|
||||||
|
artistId = artistId,
|
||||||
|
title = title,
|
||||||
|
sortTitle = sortTitle,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun TrackWire.toEntity(): CachedTrackEntity =
|
||||||
|
CachedTrackEntity(
|
||||||
|
id = id,
|
||||||
|
albumId = albumId,
|
||||||
|
artistId = artistId,
|
||||||
|
title = title,
|
||||||
|
durationMs = durationSec * MILLIS_PER_SECOND,
|
||||||
|
trackNumber = trackNumber,
|
||||||
|
discNumber = discNumber,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Detail-wire to Entity — drops the embedded array; the embedded items
|
||||||
|
// are persisted separately via the regular wire mappers in the repository.
|
||||||
|
|
||||||
|
fun ArtistDetailWire.toArtistEntity(): CachedArtistEntity =
|
||||||
|
CachedArtistEntity(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
sortName = sortName,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun AlbumDetailWire.toAlbumEntity(): CachedAlbumEntity =
|
||||||
|
CachedAlbumEntity(
|
||||||
|
id = id,
|
||||||
|
artistId = artistId,
|
||||||
|
title = title,
|
||||||
|
sortTitle = sortTitle,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entity to Domain — what ViewModels actually consume. The cache is the
|
||||||
|
// source of truth for cache-first reads; mapping to a domain type here
|
||||||
|
// keeps Room types out of the UI layer.
|
||||||
|
|
||||||
|
fun CachedArtistEntity.toDomain(): ArtistRef =
|
||||||
|
ArtistRef(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
sortName = sortName,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun CachedAlbumEntity.toDomain(artistName: String = ""): AlbumRef =
|
||||||
|
AlbumRef(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
artistId = artistId,
|
||||||
|
sortTitle = sortTitle,
|
||||||
|
artistName = artistName,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun CachedTrackEntity.toDomain(
|
||||||
|
albumTitle: String = "",
|
||||||
|
artistName: String = "",
|
||||||
|
): TrackRef =
|
||||||
|
TrackRef(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
albumId = albumId,
|
||||||
|
artistId = artistId,
|
||||||
|
albumTitle = albumTitle,
|
||||||
|
artistName = artistName,
|
||||||
|
trackNumber = trackNumber,
|
||||||
|
discNumber = discNumber,
|
||||||
|
durationSec = durationMs / MILLIS_PER_SECOND,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wire to Domain — for fresh server responses that bypass the cache
|
||||||
|
// (e.g. a not-yet-cached search hit). Same field set as Entity to
|
||||||
|
// Domain plus the wire-only fields (albumTitle, artistName, streamUrl).
|
||||||
|
|
||||||
|
fun TrackWire.toDomain(): TrackRef =
|
||||||
|
TrackRef(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
albumId = albumId,
|
||||||
|
artistId = artistId,
|
||||||
|
albumTitle = albumTitle,
|
||||||
|
artistName = artistName,
|
||||||
|
trackNumber = trackNumber,
|
||||||
|
discNumber = discNumber,
|
||||||
|
durationSec = durationSec,
|
||||||
|
streamUrl = streamUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun ArtistWire.toDomain(): ArtistRef =
|
||||||
|
ArtistRef(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
sortName = sortName,
|
||||||
|
albumCount = albumCount,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun AlbumWire.toDomain(): AlbumRef =
|
||||||
|
AlbumRef(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
artistId = artistId,
|
||||||
|
sortTitle = sortTitle,
|
||||||
|
artistName = artistName,
|
||||||
|
year = year,
|
||||||
|
trackCount = trackCount,
|
||||||
|
durationSec = durationSec,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val MILLIS_PER_SECOND = 1000
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.fabledsword.minstrel.library.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.LibraryApi
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
|
import com.fabledsword.minstrel.models.AlbumRef
|
||||||
|
import com.fabledsword.minstrel.models.ArtistRef
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache-first reads of artists/albums/tracks. The Room DAOs are the
|
||||||
|
* source of truth ViewModels observe; refresh* methods pull from the
|
||||||
|
* server and upsert into Room — the Flow emissions then propagate
|
||||||
|
* automatically.
|
||||||
|
*
|
||||||
|
* Constructs its own LibraryApi from the shared Retrofit. Per the
|
||||||
|
* "repositories own their interfaces" pattern adopted in Phase 5.1's
|
||||||
|
* NetworkModule, we don't @Provides per-endpoint Retrofit interfaces
|
||||||
|
* — fewer Hilt bindings, locality of reference.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class LibraryRepository @Inject constructor(
|
||||||
|
private val artistDao: CachedArtistDao,
|
||||||
|
private val albumDao: CachedAlbumDao,
|
||||||
|
private val trackDao: CachedTrackDao,
|
||||||
|
retrofit: Retrofit,
|
||||||
|
) {
|
||||||
|
private val api: LibraryApi = retrofit.create()
|
||||||
|
|
||||||
|
// ---- Reads (Flow, cache-first; the cache is the source of truth) ----
|
||||||
|
|
||||||
|
fun observeArtists(): Flow<List<ArtistRef>> =
|
||||||
|
artistDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||||
|
|
||||||
|
fun observeAlbums(): Flow<List<AlbumRef>> =
|
||||||
|
albumDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||||
|
|
||||||
|
fun observeAlbumsForArtist(artistId: String): Flow<List<AlbumRef>> =
|
||||||
|
albumDao.observeByArtist(artistId).map { rows -> rows.map { it.toDomain() } }
|
||||||
|
|
||||||
|
fun observeTracksForAlbum(albumId: String): Flow<List<TrackRef>> =
|
||||||
|
trackDao.observeByAlbum(albumId).map { rows -> rows.map { it.toDomain() } }
|
||||||
|
|
||||||
|
// ---- Single-shot lookups ----
|
||||||
|
|
||||||
|
suspend fun getArtist(id: String): ArtistRef? = artistDao.getById(id)?.toDomain()
|
||||||
|
|
||||||
|
suspend fun getAlbum(id: String): AlbumRef? = albumDao.getById(id)?.toDomain()
|
||||||
|
|
||||||
|
suspend fun getTrack(id: String): TrackRef? = trackDao.getById(id)?.toDomain()
|
||||||
|
|
||||||
|
// ---- Server refreshes (sync-style writes; errors propagate) ----
|
||||||
|
|
||||||
|
/** Pulls the full artist detail (ArtistRef + albums) and persists both. */
|
||||||
|
suspend fun refreshArtistDetail(id: String) {
|
||||||
|
val wire = api.getArtistDetail(id)
|
||||||
|
artistDao.upsertAll(listOf(wire.toArtistEntity()))
|
||||||
|
albumDao.upsertAll(wire.albums.map { it.toEntity() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pulls the album detail (AlbumRef + tracks) and persists both. */
|
||||||
|
suspend fun refreshAlbumDetail(id: String) {
|
||||||
|
val wire = api.getAlbumDetail(id)
|
||||||
|
albumDao.upsertAll(listOf(wire.toAlbumEntity()))
|
||||||
|
trackDao.upsertAll(wire.tracks.map { it.toEntity() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Track detail — single track, mainly for the hydration queue. */
|
||||||
|
suspend fun refreshTrack(id: String) {
|
||||||
|
val wire = api.getTrack(id)
|
||||||
|
trackDao.upsertAll(listOf(wire.toEntity()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight reference to one album. Mirrors
|
||||||
|
* `flutter_client/lib/models/album.dart`'s `AlbumRef`.
|
||||||
|
*
|
||||||
|
* `coverUrl` and `durationSec` match the server contract (not
|
||||||
|
* `cover_art_url` / `duration_ms`). `year` is omitempty server-side so
|
||||||
|
* stays nullable. `coverUrl` is non-null but may be empty — UI branches
|
||||||
|
* on isEmpty.
|
||||||
|
*/
|
||||||
|
data class AlbumRef(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val artistId: String,
|
||||||
|
val sortTitle: String = "",
|
||||||
|
val artistName: String = "",
|
||||||
|
val year: Int? = null,
|
||||||
|
val trackCount: Int = 0,
|
||||||
|
val durationSec: Int = 0,
|
||||||
|
val coverUrl: String = "",
|
||||||
|
)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight reference to one artist. Mirrors
|
||||||
|
* `flutter_client/lib/models/artist.dart`'s `ArtistRef`.
|
||||||
|
*
|
||||||
|
* `coverUrl` is the server's field name (NOT cover_art_url). Server emits
|
||||||
|
* empty string when the artist has no representative album cover; UI code
|
||||||
|
* branches on isEmpty rather than null-checking.
|
||||||
|
*/
|
||||||
|
data class ArtistRef(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val sortName: String = "",
|
||||||
|
val albumCount: Int = 0,
|
||||||
|
val coverUrl: String = "",
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight reference to one track. Mirrors
|
||||||
|
* `flutter_client/lib/models/track.dart`'s `TrackRef`.
|
||||||
|
*
|
||||||
|
* The `Ref` suffix matches the Flutter convention — these types carry
|
||||||
|
* only the IDs + display fields needed for list rendering + the player
|
||||||
|
* queue, not full per-row metadata.
|
||||||
|
*
|
||||||
|
* Constructed from `TrackWire` (server JSON) via `LibraryMappers`, and
|
||||||
|
* from `CachedTrackEntity` (Room cache) via the same mappers. Either
|
||||||
|
* source produces an equivalent TrackRef.
|
||||||
|
*/
|
||||||
|
data class TrackRef(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val albumId: String,
|
||||||
|
val artistId: String,
|
||||||
|
val albumTitle: String = "",
|
||||||
|
val artistName: String = "",
|
||||||
|
val trackNumber: Int? = null,
|
||||||
|
val discNumber: Int? = null,
|
||||||
|
val durationSec: Int = 0,
|
||||||
|
val streamUrl: String = "",
|
||||||
|
)
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package com.fabledsword.minstrel.library.data
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
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 kotlinx.serialization.json.Json
|
||||||
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class LibraryRepositoryTest {
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var retrofit: Retrofit
|
||||||
|
private val artistDao = mockk<CachedArtistDao>(relaxed = true)
|
||||||
|
private val albumDao = mockk<CachedAlbumDao>(relaxed = true)
|
||||||
|
private val trackDao = mockk<CachedTrackDao>(relaxed = true)
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setup() {
|
||||||
|
server = MockWebServer().apply { start() }
|
||||||
|
retrofit =
|
||||||
|
Retrofit.Builder()
|
||||||
|
.baseUrl(server.url("/"))
|
||||||
|
.client(OkHttpClient.Builder().build())
|
||||||
|
.addConverterFactory(
|
||||||
|
Json { ignoreUnknownKeys = true }
|
||||||
|
.asConverterFactory("application/json".toMediaType()),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun teardown() {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `observeArtists maps DAO rows to domain ArtistRefs`() = runTest {
|
||||||
|
every { artistDao.observeAll() } returns
|
||||||
|
flowOf(
|
||||||
|
listOf(
|
||||||
|
CachedArtistEntity(id = "a1", name = "Boards of Canada", sortName = "Boards of Canada"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit)
|
||||||
|
|
||||||
|
repo.observeArtists().test {
|
||||||
|
val first = awaitItem()
|
||||||
|
assertEquals(1, first.size)
|
||||||
|
assertEquals("a1", first[0].id)
|
||||||
|
assertEquals("Boards of Canada", first[0].name)
|
||||||
|
awaitComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refreshArtistDetail upserts the artist + the embedded albums`() = runTest {
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setHeader("Content-Type", "application/json")
|
||||||
|
.setBody(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"id": "a1",
|
||||||
|
"name": "Boards of Canada",
|
||||||
|
"sort_name": "Boards of Canada",
|
||||||
|
"album_count": 2,
|
||||||
|
"cover_url": "",
|
||||||
|
"albums": [
|
||||||
|
{"id": "al1", "title": "Music Has the Right to Children", "artist_id": "a1", "sort_title": "Music Has the Right to Children"},
|
||||||
|
{"id": "al2", "title": "Geogaddi", "artist_id": "a1", "sort_title": "Geogaddi"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
coEvery { artistDao.upsertAll(any()) } returns Unit
|
||||||
|
coEvery { albumDao.upsertAll(any()) } returns Unit
|
||||||
|
val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit)
|
||||||
|
|
||||||
|
repo.refreshArtistDetail("a1")
|
||||||
|
|
||||||
|
coVerify { artistDao.upsertAll(match { it.size == 1 && it[0].id == "a1" }) }
|
||||||
|
coVerify { albumDao.upsertAll(match { it.size == 2 && it[0].id == "al1" && it[1].id == "al2" }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getArtist returns null when DAO is empty`() = runTest {
|
||||||
|
coEvery { artistDao.getById("missing") } returns null
|
||||||
|
val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit)
|
||||||
|
|
||||||
|
val result = repo.getArtist("missing")
|
||||||
|
|
||||||
|
assertEquals(null, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user