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