From 1f4a5e08bdc1ed29887e7f5eedf27c305b928d20 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 26 May 2026 12:13:27 -0400 Subject: [PATCH] feat(android): MutationQueue PLAYLIST_APPEND + QUARANTINE_FLAG kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../minstrel/api/endpoints/PlaylistsApi.kt | 28 ++++++++-- .../minstrel/cache/mutations/MutationQueue.kt | 54 +++++++++++++++++++ .../cache/mutations/MutationReplayer.kt | 41 ++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt index f1206862..64b1e4fd 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/PlaylistsApi.kt @@ -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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt index 3091df94..4f80e5e2 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt @@ -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, + ): 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, +) + +/** + * 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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt index a9be65b3..6c05e087 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt @@ -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 + } + } }