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