feat(android): Phase 15 — SyncController for /api/library/sync delta sync

Closes the Library-tabs-start-empty gap. Mirrors
`flutter_client/lib/cache/sync_controller.dart`, scoped to artist /
album / track only (likes refresh via /api/likes/ids; playlists pull
on screen visit). The full multi-entity sync is overkill for v1
native.

New:
  - models/wire/SyncResponseWire.kt — SyncArtistWire +
    SyncAlbumWire + SyncTrackWire (raw DB-row shape returned by
    /api/library/sync; distinct from the regular API's display
    shapes which include derived album_count / cover_url etc.).
    Plus SyncUpsertsWire / SyncDeletesWire / SyncResponseWire
    envelopes.
  - api/endpoints/SyncApi.kt — Retrofit GET /api/library/sync.
    Returns Response<...> so the controller can branch on 200 /
    204 (no changes since cursor) / 410 (cursor too old, wipe +
    retry) without HttpException catches.
  - cache/sync/SyncController.kt — @Singleton. Self-starting: on
    construction subscribes to AuthStore.sessionCookie and fires
    syncSafe() whenever it transitions to a non-null value (fresh
    sign-in OR cold start with persisted cookie). Applies upserts
    via the existing upsertAll DAO methods, applies deletes via
    deleteByIds. Cursor + lastSyncAt persisted in sync_metadata so
    the next sync resumes from the new watermark.
    On 410 (server compaction window exceeded), resets the cursor
    to 0 and recurses; the next response carries the full entity
    set, which upsertAll overwrites with. Stale rows for entities
    the server no longer knows about linger until a later 410 or
    app-data-clear — acceptable for v1.

Modified:
  - MinstrelApplication.kt — adds @Inject lateinit var
    syncController (same construct-the-singleton trick used for
    ResumeController). Without the @Inject the Hilt graph would
    never instantiate the controller and its init {} cookie
    observer wouldn't subscribe.

Likes / playlists / playlist_tracks deltas from the same endpoint
are deferred. Their dedicated refresh paths already populate the
local cache; folding them into the sync flow is an opportunistic
optimization, not an MVP gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 19:39:22 -04:00
parent 7dfe6f1b69
commit 66dfc473db
4 changed files with 299 additions and 0 deletions
@@ -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(),
)