ci: drop push:dev trigger; PR is the gate #25
@@ -6,6 +6,7 @@ import androidx.work.Configuration
|
|||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.player.ResumeController
|
import com.fabledsword.minstrel.player.ResumeController
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
@@ -38,6 +39,15 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Inject lateinit var resumeController: ResumeController
|
@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
|
@Inject @ApplicationScope lateinit var appScope: CoroutineScope
|
||||||
|
|
||||||
override fun onCreate() {
|
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