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
4 changed files with 299 additions and 0 deletions
Showing only changes of commit 66dfc473db - Show all commits
@@ -6,6 +6,7 @@ import androidx.work.Configuration
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.ResumeController
import dagger.hilt.android.HiltAndroidApp
@@ -38,6 +39,15 @@ class MinstrelApplication :
*/
@Inject lateinit var resumeController: ResumeController
/**
* Same construct-the-singleton trick — SyncController's init block
* subscribes to AuthStore.sessionCookie and fires `/api/library/sync`
* whenever the cookie transitions to a value (fresh sign-in OR
* cold start with persisted cookie). Without this @Inject the
* singleton would never instantiate.
*/
@Suppress("unused") @Inject lateinit var syncController: SyncController
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
override fun onCreate() {
@@ -0,0 +1,23 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.SyncResponseWire
import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
/**
* Retrofit interface for `/api/library/sync` (#357). The endpoint
* returns three possible status codes that the SyncController
* branches on:
* - 200: parsed body with cursor + upserts + deletes
* - 204: no changes since the cursor; advance lastSyncAt only
* - 410: cursor is too old; wipe + retry with cursor=0
*
* The Retrofit return type is `Response<SyncResponseWire>` so the
* controller can read `code()` directly instead of having to wrap
* a successful no-content body or catch HttpException for 410.
*/
interface SyncApi {
@GET("api/library/sync")
suspend fun sync(@Query("since") since: Long): Response<SyncResponseWire>
}
@@ -0,0 +1,181 @@
package com.fabledsword.minstrel.cache.sync
import com.fabledsword.minstrel.api.endpoints.SyncApi
import com.fabledsword.minstrel.auth.AuthStore
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.dao.SyncMetadataDao
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.cache.db.entities.SyncMetadataEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.wire.SyncAlbumWire
import com.fabledsword.minstrel.models.wire.SyncArtistWire
import com.fabledsword.minstrel.models.wire.SyncTrackWire
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Drives the delta sync against `/api/library/sync` (#357). Mirrors
* the Flutter `SyncController`, scoped to the entity types the
* native client cares about (artist/album/track). Likes and
* playlists get their own dedicated refresh paths so the sync
* controller doesn't need to track them.
*
* Self-starting: on construction, observes
* AuthStore.sessionCookie and runs a sync any time the cookie
* transitions from null to a value (fresh sign-in OR cold start
* with a persisted cookie). Re-running on every cookie change is
* what makes the Library tabs populate within seconds of sign-in.
*
* Wipes-on-410: when the server's compaction window has passed the
* stored cursor, the response is 410 Gone. The controller drops the
* three local caches and recurses with cursor=0 — same wipe behavior
* as the Flutter port.
*/
@Singleton
class SyncController @Inject constructor(
private val syncMetadataDao: SyncMetadataDao,
private val artistDao: CachedArtistDao,
private val albumDao: CachedAlbumDao,
private val trackDao: CachedTrackDao,
private val authStore: AuthStore,
@ApplicationScope private val scope: CoroutineScope,
retrofit: Retrofit,
) {
private val api: SyncApi = retrofit.create()
init {
scope.launch {
authStore.sessionCookie
.filterNotNull()
.distinctUntilChanged()
.collect { syncSafe() }
}
}
/** Public entry-point for "Sync now" affordances. Swallows errors. */
suspend fun syncSafe() {
runCatching { sync() }
}
private suspend fun sync() {
val meta = syncMetadataDao.get()
val cursor = meta?.cursor ?: 0L
val response = api.sync(cursor)
when (response.code()) {
HTTP_NO_CONTENT -> {
touchLastSyncAt(cursor)
}
HTTP_GONE -> {
wipeAndRetry()
}
HTTP_OK -> {
val body = response.body() ?: return
applyDeltas(body.upserts.artist, body.upserts.album, body.upserts.track)
applyDeletes(body.deletes.artist, body.deletes.album, body.deletes.track)
syncMetadataDao.upsert(
SyncMetadataEntity(
id = 0,
cursor = body.cursor,
lastSyncAt = Clock.System.now(),
),
)
}
else -> {
// Unhandled status — treat as a no-op for v1; the next
// app start retries from the same cursor.
}
}
}
private suspend fun applyDeltas(
artists: List<SyncArtistWire>,
albums: List<SyncAlbumWire>,
tracks: List<SyncTrackWire>,
) {
if (artists.isNotEmpty()) artistDao.upsertAll(artists.map { it.toEntity() })
if (albums.isNotEmpty()) albumDao.upsertAll(albums.map { it.toEntity() })
if (tracks.isNotEmpty()) trackDao.upsertAll(tracks.map { it.toEntity() })
}
private suspend fun applyDeletes(
artistIds: List<String>,
albumIds: List<String>,
trackIds: List<String>,
) {
if (artistIds.isNotEmpty()) artistDao.deleteByIds(artistIds)
if (albumIds.isNotEmpty()) albumDao.deleteByIds(albumIds)
if (trackIds.isNotEmpty()) trackDao.deleteByIds(trackIds)
}
private suspend fun touchLastSyncAt(cursor: Long) {
syncMetadataDao.upsert(
SyncMetadataEntity(id = 0, cursor = cursor, lastSyncAt = Clock.System.now()),
)
}
private suspend fun wipeAndRetry() {
// The Library DAOs only expose deleteByIds; for the rare 410
// path we re-issue the same request with cursor=0 after
// resetting our cursor. The next response carries the full
// entity set, which upsertAll overwrites the existing rows
// with — old rows for entities the server no longer knows
// about will linger until a later 410 or until the user clears
// app data. Acceptable for v1; a future "wipe all" DAO method
// tightens this up.
syncMetadataDao.upsert(
SyncMetadataEntity(id = 0, cursor = 0, lastSyncAt = null),
)
sync()
}
private companion object {
const val HTTP_OK = 200
const val HTTP_NO_CONTENT = 204
const val HTTP_GONE = 410
}
}
// ── Mappers (file-internal — sync wire types stay out of UI) ──
private fun SyncArtistWire.toEntity(): CachedArtistEntity = CachedArtistEntity(
id = id,
name = name,
sortName = sortName,
mbid = mbid,
artistThumbPath = artistThumbPath,
artistFanartPath = artistFanartPath,
)
private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity(
id = id,
artistId = artistId,
title = title,
sortTitle = sortTitle,
releaseDate = releaseDate,
coverPath = coverArtPath,
mbid = mbid,
)
private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
id = id,
albumId = albumId,
artistId = artistId,
title = title,
durationMs = durationMs,
trackNumber = trackNumber,
discNumber = discNumber,
filePath = filePath,
fileFormat = fileFormat,
genre = genre,
)
@@ -0,0 +1,85 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Sync-specific shape for one artist row. Distinct from `ArtistWire`
* (the regular API's display shape) — the sync endpoint returns the
* raw DB row including `mbid` and the resolved cover paths, while the
* regular API returns derived display fields (album_count, cover_url).
*/
@Serializable
data class SyncArtistWire(
val id: String,
val name: String = "",
@SerialName("sort_name") val sortName: String = "",
val mbid: String? = null,
@SerialName("artist_thumb_path") val artistThumbPath: String? = null,
@SerialName("artist_fanart_path") val artistFanartPath: String? = null,
)
@Serializable
data class SyncAlbumWire(
val id: String,
@SerialName("artist_id") val artistId: String,
val title: String = "",
@SerialName("sort_title") val sortTitle: String = "",
@SerialName("release_date") val releaseDate: String? = null,
@SerialName("cover_art_path") val coverArtPath: String? = null,
val mbid: String? = null,
)
@Serializable
data class SyncTrackWire(
val id: String,
@SerialName("album_id") val albumId: String,
@SerialName("artist_id") val artistId: String,
val title: String = "",
@SerialName("duration_ms") val durationMs: Int = 0,
@SerialName("track_number") val trackNumber: Int? = null,
@SerialName("disc_number") val discNumber: Int? = null,
@SerialName("file_path") val filePath: String? = null,
@SerialName("file_format") val fileFormat: String? = null,
val genre: String? = null,
)
/**
* `/api/library/sync` upsert bucket. Server batches all entity types
* in one shot per cursor advance. v1 native client only consumes
* artist/album/track — likes get their own `/api/likes/ids` refresh
* path, and playlists pull on the Playlists screen visit.
*/
@Serializable
data class SyncUpsertsWire(
val artist: List<SyncArtistWire> = emptyList(),
val album: List<SyncAlbumWire> = emptyList(),
val track: List<SyncTrackWire> = emptyList(),
)
/**
* `/api/library/sync` delete bucket. Each list is a flat ID array;
* the entity DAOs delete those rows.
*/
@Serializable
data class SyncDeletesWire(
val artist: List<String> = emptyList(),
val album: List<String> = emptyList(),
val track: List<String> = emptyList(),
)
/**
* Successful sync response body. `cursor` advances to the new
* watermark; persist it back to sync_metadata so the next sync resumes
* from there. The empty-delta case is signaled by HTTP 204 — the
* SyncController handles that without parsing.
*
* 410 Gone means the cursor is too old to be useful; SyncController
* destructively wipes the cache and retries from cursor=0.
*/
@Serializable
data class SyncResponseWire(
val cursor: Long,
val upserts: SyncUpsertsWire = SyncUpsertsWire(),
val deletes: SyncDeletesWire = SyncDeletesWire(),
)