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 = "",
)