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:
2026-05-22 22:27:47 -04:00
parent d08c937f3b
commit 9eaaf93f23
6 changed files with 396 additions and 0 deletions
@@ -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 = "",
)
@@ -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)
}
}