diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt new file mode 100644 index 00000000..d58aaea9 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryMappers.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt new file mode 100644 index 00000000..5a64dd0b --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/library/data/LibraryRepository.kt @@ -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> = + artistDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + fun observeAlbums(): Flow> = + albumDao.observeAll().map { rows -> rows.map { it.toDomain() } } + + fun observeAlbumsForArtist(artistId: String): Flow> = + albumDao.observeByArtist(artistId).map { rows -> rows.map { it.toDomain() } } + + fun observeTracksForAlbum(albumId: String): Flow> = + 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())) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumRef.kt new file mode 100644 index 00000000..d5a3fbad --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/AlbumRef.kt @@ -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 = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt new file mode 100644 index 00000000..be1614ec --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/ArtistRef.kt @@ -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 = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt new file mode 100644 index 00000000..724e180c --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/TrackRef.kt @@ -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 = "", +) diff --git a/android/app/src/test/java/com/fabledsword/minstrel/library/data/LibraryRepositoryTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/library/data/LibraryRepositoryTest.kt new file mode 100644 index 00000000..c35988b7 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/library/data/LibraryRepositoryTest.kt @@ -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(relaxed = true) + private val albumDao = mockk(relaxed = true) + private val trackDao = mockk(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) + } +}