feat: M3 weighted shuffle v1 — real /api/radio with scoring #21

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 11:00:29 -04:00
2 changed files with 82 additions and 0 deletions
Showing only changes of commit 92c128e388 - Show all commits
@@ -0,0 +1,31 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
import com.fabledsword.minstrel.models.wire.PlaySkippedRequest
import com.fabledsword.minstrel.models.wire.PlayStartedRequest
import com.fabledsword.minstrel.models.wire.PlayStartedResponse
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Retrofit interface for `POST /api/events`. Mirrors the relevant
* slice of `flutter_client/lib/api/endpoints/events.dart`. All four
* variants share the same URL — the discriminator is in the request
* body's `type` field. Server contract is best-effort per spec;
* callers (the live path in PlayEventsReporter) swallow errors and
* fall back to the offline mutation queue on failure.
*/
interface EventsApi {
@POST("api/events")
suspend fun playStarted(@Body body: PlayStartedRequest): PlayStartedResponse
@POST("api/events")
suspend fun playEnded(@Body body: PlayEndedRequest)
@POST("api/events")
suspend fun playSkipped(@Body body: PlaySkippedRequest)
@POST("api/events")
suspend fun playOffline(@Body body: PlayOfflineRequest)
}
@@ -0,0 +1,51 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire shapes for `POST /api/events`. The endpoint multiplexes four
* variants on the `type` discriminator field, mirroring
* `flutter_client/lib/api/endpoints/events.dart`.
*
* play_started returns the server-assigned play_event_id (nullable —
* server may suppress under certain conditions); the other three
* variants return 204-equivalent (no body).
*/
@Serializable
data class PlayStartedRequest(
val type: String = "play_started",
@SerialName("track_id") val trackId: String,
@SerialName("client_id") val clientId: String,
val source: String? = null,
)
@Serializable
data class PlayStartedResponse(
@SerialName("play_event_id") val playEventId: String? = null,
)
@Serializable
data class PlayEndedRequest(
val type: String = "play_ended",
@SerialName("play_event_id") val playEventId: String,
@SerialName("duration_played_ms") val durationPlayedMs: Long,
)
@Serializable
data class PlaySkippedRequest(
val type: String = "play_skipped",
@SerialName("play_event_id") val playEventId: String,
@SerialName("position_ms") val positionMs: Long,
)
@Serializable
data class PlayOfflineRequest(
val type: String = "play_offline",
@SerialName("track_id") val trackId: String,
@SerialName("client_id") val clientId: String,
val at: String,
@SerialName("duration_played_ms") val durationPlayedMs: Long,
val source: String? = null,
)