feat(android): MutationQueue PLAYLIST_APPEND + QUARANTINE_FLAG kinds

Extends the offline mutation queue with the two new write kinds the
TrackActions menu produces — appending a track to a playlist, and
flagging a track for quarantine. Replayer re-fires with idempotent
server semantics; payload data classes mirror Flutter's wire shapes.
Also adds PlaylistsApi.appendTracks since the replayer needs the
Retrofit surface in the same change set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 12:13:27 -04:00
parent ac1832da43
commit 1f4a5e08bd
3 changed files with 120 additions and 3 deletions
@@ -2,16 +2,17 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
import com.fabledsword.minstrel.models.wire.PlaylistsListWire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
/**
* Retrofit interface for `/api/playlists`. Mirrors
* `flutter_client/lib/api/endpoints/playlists.dart`.
*
* Read-only for now — playlist creation, track-append, and the system
* shuffle/refresh endpoints come in with the mutation-queue phase.
*/
interface PlaylistsApi {
/**
@@ -25,4 +26,25 @@ interface PlaylistsApi {
@GET("api/playlists/{id}")
suspend fun get(@Path("id") id: String): PlaylistDetailWire
/**
* POST /api/playlists/{id}/tracks — append the given track IDs to
* the owner's playlist. Server is idempotent at the (playlist_id,
* track_id) pair level; re-firing the same payload yields no
* duplicates.
*/
@POST("api/playlists/{id}/tracks")
suspend fun appendTracks(
@Path("id") playlistId: String,
@Body body: AppendTracksRequest,
)
}
/**
* POST /api/playlists/{id}/tracks body. Server expects snake_case
* `track_ids` per the Flutter wire shape.
*/
@Serializable
data class AppendTracksRequest(
@SerialName("track_ids") val trackIds: List<String>,
)
@@ -17,6 +17,8 @@ object MutationKind {
const val LIKE_TOGGLE: String = "like_toggle"
const val REQUEST_CREATE: String = "request_create"
const val QUARANTINE_UNFLAG: String = "quarantine_unflag"
const val PLAYLIST_APPEND: String = "playlist_append"
const val QUARANTINE_FLAG: String = "quarantine_flag"
}
/**
@@ -96,6 +98,33 @@ class MutationQueue @Inject constructor(
),
),
)
suspend fun enqueuePlaylistAppend(
playlistId: String,
trackIds: List<String>,
): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.PLAYLIST_APPEND,
payload = json.encodeToString(
PlaylistAppendPayload.serializer(),
PlaylistAppendPayload(playlistId, trackIds),
),
),
)
suspend fun enqueueQuarantineFlag(
trackId: String,
reason: String,
notes: String,
): Long = dao.insert(
CachedMutationEntity(
kind = MutationKind.QUARANTINE_FLAG,
payload = json.encodeToString(
QuarantineFlagPayload.serializer(),
QuarantineFlagPayload(trackId, reason, notes),
),
),
)
}
/**
@@ -105,3 +134,28 @@ class MutationQueue @Inject constructor(
*/
@Serializable
data class QuarantineUnflagPayload(val trackId: String)
/**
* Persisted payload for `MutationKind.PLAYLIST_APPEND`. Mirrors the
* Flutter wire shape: list of track IDs to append to a specific
* playlist. Server is idempotent at the (playlist_id, track_id) pair
* level — re-firing the same payload yields no duplicates.
*/
@Serializable
data class PlaylistAppendPayload(
val playlistId: String,
val trackIds: List<String>,
)
/**
* Persisted payload for `MutationKind.QUARANTINE_FLAG`. The replayer
* re-fires `POST /api/quarantine` with this body when the original
* call failed; server-side, re-flagging an already-flagged (user,
* track) pair is idempotent.
*/
@Serializable
data class QuarantineFlagPayload(
val trackId: String,
val reason: String,
val notes: String,
)
@@ -1,7 +1,10 @@
package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
@@ -57,6 +60,7 @@ class MutationReplayer @Inject constructor(
private val likesApi: LikesApi = retrofit.create()
private val discoverApi: DiscoverApi = retrofit.create()
private val quarantineApi: QuarantineApi = retrofit.create()
private val playlistsApi: PlaylistsApi = retrofit.create()
private val mutex = Mutex()
@@ -103,6 +107,8 @@ class MutationReplayer @Inject constructor(
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
else -> {
// Unknown kind — drop the row by claiming success so a
// stale schema entry can't wedge the queue forever.
@@ -176,4 +182,39 @@ class MutationReplayer @Inject constructor(
false
}
}
private suspend fun dispatchPlaylistAppend(payload: String): Boolean {
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
return try {
playlistsApi.appendTracks(
playlistId = decoded.playlistId,
body = AppendTracksRequest(trackIds = decoded.trackIds),
)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
}
private suspend fun dispatchQuarantineFlag(payload: String): Boolean {
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
return try {
quarantineApi.flag(
FlagRequest(
trackId = decoded.trackId,
reason = decoded.reason,
notes = decoded.notes,
),
)
true
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
// Intentional swallow: row stays queued; next drain pass retries.
false
}
}
}