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 = "",
|
||||
)
|
||||
Reference in New Issue
Block a user