From 66dfc473dba7ad282f65884c0113398105ce30bb Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 25 May 2026 19:39:22 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Phase=2015=20=E2=80=94=20SyncC?= =?UTF-8?q?ontroller=20for=20/api/library/sync=20delta=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../minstrel/MinstrelApplication.kt | 10 + .../minstrel/api/endpoints/SyncApi.kt | 23 +++ .../minstrel/cache/sync/SyncController.kt | 181 ++++++++++++++++++ .../minstrel/models/wire/SyncResponseWire.kt | 85 ++++++++ 4 files changed, 299 insertions(+) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SyncApi.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index 7c5bfe6f..c1b040d0 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -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() { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SyncApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SyncApi.kt new file mode 100644 index 00000000..4baf1783 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/SyncApi.kt @@ -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` 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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt new file mode 100644 index 00000000..d50fac19 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/sync/SyncController.kt @@ -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, + albums: List, + tracks: List, + ) { + 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, + albumIds: List, + trackIds: List, + ) { + 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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt new file mode 100644 index 00000000..23bbc822 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/SyncResponseWire.kt @@ -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 = emptyList(), + val album: List = emptyList(), + val track: List = 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 = emptyList(), + val album: List = emptyList(), + val track: List = 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(), +)