Files
minstrel/internal/api/events.go
T
bvandeusen d2a0b7d780 feat(playlists): #415 stage 1 — rotation-state schema + play ingest
First of three stages for system-playlist sample-history dedup
(Fable #415, server-side rotation per the operator's choice).

Schema (migration 0027):
- play_events.source (nullable text): which surface a play came
  from. 'for_you' / 'discover' feed rotation; NULL for library /
  user-playlist / radio / Subsonic.
- system_playlist_rotation_state(user_id, playlist_kind,
  played_track_ids uuid[], rotation_started_at, updated_at): the
  per-(user,kind) set of already-heard tracks this rotation.

Ingest:
- New RecordPlayStartedWithSource on the writer; RecordPlayStarted
  is now a thin source="" wrapper so the frozen Subsonic shim is
  untouched (no signature ripple).
- When source is a known system kind, the same txn appends the
  track to rotation state (AppendRotationPlayed keeps the array a
  set via the conflict CASE).
- /api/events play_started accepts an optional "source".

No serve-behavior change yet — Stage 2 makes shuffle prefer the
unplayed tail + resets on exhaustion; Stage 3 wires the clients to
send source and consume the rotation-aware order.

Tests: rotation appends + dedupes for a system source; source-less
play writes no rotation row. (Existing RecordPlayStarted tests are
unchanged — same wrapper signature, identical behavior at source="".)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:40:17 -04:00

176 lines
5.3 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
type eventRequest struct {
Type string `json:"type"`
TrackID string `json:"track_id"`
PlayEventID string `json:"play_event_id"`
DurationPlayedMs *int32 `json:"duration_played_ms"`
PositionMs *int32 `json:"position_ms"`
At *string `json:"at"`
ClientID *string `json:"client_id"`
// Source tags which surface a play_started came from. "for_you"
// / "discover" feed the system-playlist rotation dedup (#415);
// absent / "" for library, user-playlist, radio, Subsonic.
Source *string `json:"source"`
}
type playStartedResponse struct {
PlayEventID string `json:"play_event_id"`
SessionID string `json:"session_id"`
}
type okResponse struct {
OK bool `json:"ok"`
}
func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
var req eventRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, apierror.BadRequest("bad_request", "invalid JSON body"))
return
}
at := time.Now().UTC()
if req.At != nil && *req.At != "" {
parsed, err := time.Parse(time.RFC3339, *req.At)
if err != nil {
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
return
}
at = parsed
}
clientID := ""
if req.ClientID != nil {
clientID = *req.ClientID
}
switch req.Type {
case "play_started":
h.handleEventPlayStarted(w, r, user, req, at, clientID)
case "play_ended":
h.handleEventPlayEnded(w, r, user, req, at)
case "play_skipped":
h.handleEventPlaySkipped(w, r, user, req, at)
default:
writeErr(w, apierror.BadRequest("bad_request", "unknown event type"))
}
}
func (h *handlers) handleEventPlayStarted(
w http.ResponseWriter, r *http.Request,
user dbq.User, req eventRequest, at time.Time, clientID string,
) {
trackID, ok := parseUUID(req.TrackID)
if !ok {
writeErr(w, apierror.BadRequest("bad_request", "invalid track_id"))
return
}
if _, err := dbq.New(h.pool).GetTrackByID(r.Context(), trackID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "track not found"})
return
}
h.logger.Error("api: events: lookup track", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
source := ""
if req.Source != nil {
source = *req.Source
}
res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at)
if err != nil {
h.logger.Error("api: events: play_started", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err))
return
}
writeJSON(w, http.StatusOK, playStartedResponse{
PlayEventID: uuidToString(res.PlayEventID),
SessionID: uuidToString(res.SessionID),
})
}
func (h *handlers) handleEventPlayEnded(
w http.ResponseWriter, r *http.Request,
user dbq.User, req eventRequest, at time.Time,
) {
playEventID, ok := parseUUID(req.PlayEventID)
if !ok {
writeErr(w, apierror.BadRequest("bad_request", "invalid play_event_id"))
return
}
if req.DurationPlayedMs == nil || *req.DurationPlayedMs < 0 {
writeErr(w, apierror.BadRequest("bad_request", "duration_played_ms required and must be >= 0"))
return
}
ev, err := dbq.New(h.pool).GetPlayEventByID(r.Context(), playEventID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "play_event not found"})
return
}
h.logger.Error("api: events: lookup play_event", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
if ev.UserID != user.ID {
writeErr(w, apierror.Forbidden("forbidden", "play_event belongs to a different user"))
return
}
if err := h.events.RecordPlayEnded(r.Context(), playEventID, *req.DurationPlayedMs, at); err != nil {
h.logger.Error("api: events: play_ended", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err))
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}
func (h *handlers) handleEventPlaySkipped(
w http.ResponseWriter, r *http.Request,
user dbq.User, req eventRequest, at time.Time,
) {
playEventID, ok := parseUUID(req.PlayEventID)
if !ok {
writeErr(w, apierror.BadRequest("bad_request", "invalid play_event_id"))
return
}
if req.PositionMs == nil || *req.PositionMs < 0 {
writeErr(w, apierror.BadRequest("bad_request", "position_ms required and must be >= 0"))
return
}
ev, err := dbq.New(h.pool).GetPlayEventByID(r.Context(), playEventID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, &apierror.Error{Status: 404, Code: "not_found", Message: "play_event not found"})
return
}
h.logger.Error("api: events: lookup play_event", "err", err)
writeErr(w, apierror.InternalMsg("lookup failed", err))
return
}
if ev.UserID != user.ID {
writeErr(w, apierror.Forbidden("forbidden", "play_event belongs to a different user"))
return
}
if err := h.events.RecordPlaySkipped(r.Context(), playEventID, *req.PositionMs, at); err != nil {
h.logger.Error("api: events: play_skipped", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err))
return
}
writeJSON(w, http.StatusOK, okResponse{OK: true})
}