feat: M3 weighted shuffle v1 — real /api/radio with scoring #21

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 11:00:29 -04:00
6 changed files with 49 additions and 16 deletions
Showing only changes of commit d7dda2fcef - Show all commits
@@ -64,11 +64,12 @@ fun AlbumDetailWire.toAlbumEntity(): CachedAlbumEntity =
// 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 =
fun CachedArtistEntity.toDomain(coverAlbumId: String = ""): ArtistRef =
ArtistRef(
id = id,
name = name,
sortName = sortName,
coverAlbumId = coverAlbumId,
)
fun CachedAlbumEntity.toDomain(artistName: String = ""): AlbumRef =
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.models.ArtistDetailRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
import retrofit2.create
@@ -43,8 +44,17 @@ class LibraryRepository @Inject constructor(
// ---- Reads (Flow, cache-first; the cache is the source of truth) ----
// Joins each artist to its first cached album (by sort title) so the
// tile cover derives from /api/albums/{id}/cover — cached_artists
// stores no cover and sync carries none. Mirrors Flutter's
// artistTileProvider JOIN.
fun observeArtists(): Flow<List<ArtistRef>> =
artistDao.observeAll().map { rows -> rows.map { it.toDomain() } }
combine(artistDao.observeAll(), albumDao.observeAll()) { artists, albums ->
val firstAlbumByArtist = albums
.groupBy { it.artistId }
.mapValues { (_, list) -> list.minByOrNull { it.sortTitle }?.id ?: "" }
artists.map { it.toDomain(coverAlbumId = firstAlbumByArtist[it.id] ?: "") }
}
fun observeAlbums(): Flow<List<AlbumRef>> =
albumDao.observeAll().map { rows -> rows.map { it.toDomain() } }
@@ -19,15 +19,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.User
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
import com.fabledsword.minstrel.shared.widgets.ServerImage
/**
* One artist tile. Circular cover; name centered beneath. Tap fires
@@ -61,20 +60,17 @@ fun ArtistCard(
.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
if (artist.coverUrl.isEmpty()) {
ServerImage(
url = artist.displayCoverUrl,
contentDescription = artist.name,
modifier = Modifier.fillMaxSize(),
) {
Icon(
imageVector = Lucide.User,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(56.dp),
)
} else {
AsyncImage(
model = artist.coverUrl,
contentDescription = artist.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
}
}
Spacer(Modifier.height(8.dp))
@@ -12,6 +12,7 @@ import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@@ -70,9 +71,12 @@ class MetadataProvider @Inject constructor(
.onEach { row -> if (row == null) fetchAlbum(id) }
fun observeArtist(id: String): Flow<ArtistRef?> =
artistDao.observeById(id)
.map { it?.toDomain() }
.onEach { row -> if (row == null) fetchArtist(id) }
combine(
artistDao.observeById(id),
albumDao.observeByArtist(id),
) { artist, albums ->
artist?.toDomain(coverAlbumId = albums.minByOrNull { it.sortTitle }?.id ?: "")
}.onEach { row -> if (row == null) fetchArtist(id) }
fun observeTrack(id: String): Flow<TrackRef?> =
trackDao.observeById(id)
@@ -14,4 +14,23 @@ data class ArtistRef(
val sortName: String = "",
val albumCount: Int = 0,
val coverUrl: String = "",
)
val coverAlbumId: String = "",
) {
/**
* Cover URL the UI should render, or null when the artist has no
* representative cover. Prefers the server-provided `coverUrl`
* (relative, e.g. `/api/artists` ships it); otherwise derives the
* first cached album's `/api/albums/{id}/cover` via [coverAlbumId].
*
* The fallback matters because cached_artists stores no cover and
* the sync payload carries none — so for cache-sourced artist tiles
* we mirror Flutter's `artistTileProvider`, which JOINs the first
* album to supply the cover. Resolve through `ServerImage`.
*/
val displayCoverUrl: String?
get() = when {
coverUrl.isNotEmpty() -> coverUrl
coverAlbumId.isNotEmpty() -> "/api/albums/$coverAlbumId/cover"
else -> null
}
}
@@ -58,6 +58,9 @@ class LibraryRepositoryTest {
CachedArtistEntity(id = "a1", name = "Boards of Canada", sortName = "Boards of Canada"),
),
)
// observeArtists now combines with albums (to derive the tile
// cover from the first album); stub it so combine can emit.
every { albumDao.observeAll() } returns flowOf(emptyList())
val repo = LibraryRepository(artistDao, albumDao, trackDao, retrofit)
repo.observeArtists().test {