release v2026.05.11.3: caching, perf, playlist polish #41
@@ -2,16 +2,17 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
|
|
||||||
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
|
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
|
||||||
import com.fabledsword.minstrel.models.wire.PlaylistsListWire
|
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.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit interface for `/api/playlists`. Mirrors
|
* Retrofit interface for `/api/playlists`. Mirrors
|
||||||
* `flutter_client/lib/api/endpoints/playlists.dart`.
|
* `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 {
|
interface PlaylistsApi {
|
||||||
/**
|
/**
|
||||||
@@ -25,4 +26,25 @@ interface PlaylistsApi {
|
|||||||
|
|
||||||
@GET("api/playlists/{id}")
|
@GET("api/playlists/{id}")
|
||||||
suspend fun get(@Path("id") id: String): PlaylistDetailWire
|
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 LIKE_TOGGLE: String = "like_toggle"
|
||||||
const val REQUEST_CREATE: String = "request_create"
|
const val REQUEST_CREATE: String = "request_create"
|
||||||
const val QUARANTINE_UNFLAG: String = "quarantine_unflag"
|
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
|
@Serializable
|
||||||
data class QuarantineUnflagPayload(val trackId: String)
|
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
|
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.DiscoverApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
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.api.endpoints.QuarantineApi
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
@@ -57,6 +60,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
private val likesApi: LikesApi = retrofit.create()
|
private val likesApi: LikesApi = retrofit.create()
|
||||||
private val discoverApi: DiscoverApi = retrofit.create()
|
private val discoverApi: DiscoverApi = retrofit.create()
|
||||||
private val quarantineApi: QuarantineApi = retrofit.create()
|
private val quarantineApi: QuarantineApi = retrofit.create()
|
||||||
|
private val playlistsApi: PlaylistsApi = retrofit.create()
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -103,6 +107,8 @@ class MutationReplayer @Inject constructor(
|
|||||||
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
||||||
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
|
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
|
||||||
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
||||||
|
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
||||||
|
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
||||||
else -> {
|
else -> {
|
||||||
// Unknown kind — drop the row by claiming success so a
|
// Unknown kind — drop the row by claiming success so a
|
||||||
// stale schema entry can't wedge the queue forever.
|
// stale schema entry can't wedge the queue forever.
|
||||||
@@ -176,4 +182,39 @@ class MutationReplayer @Inject constructor(
|
|||||||
false
|
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