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:
@@ -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>
|
||||
}
|
||||
+181
@@ -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(),
|
||||
)
|
||||
Reference in New Issue
Block a user