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>
This commit is contained in:
@@ -57,12 +57,36 @@ type StartedResult struct {
|
||||
|
||||
// RecordPlayStarted: in one transaction, auto-close any prior open row for
|
||||
// the user, FindOrCreate the play_session, insert the new play_event,
|
||||
// return its id and session id.
|
||||
// return its id and session id. Source-agnostic — kept for the Subsonic
|
||||
// shim (frozen API), which never originates from a system playlist.
|
||||
func (w *Writer) RecordPlayStarted(
|
||||
ctx context.Context,
|
||||
userID, trackID pgtype.UUID,
|
||||
clientID string,
|
||||
at time.Time,
|
||||
) (StartedResult, error) {
|
||||
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", at)
|
||||
}
|
||||
|
||||
// systemPlaylistSources are the play_events.source values that count
|
||||
// against a system playlist's rotation (Fable #415). Add future
|
||||
// system-playlist kinds here as they ship (deep_cuts, rediscover, …).
|
||||
var systemPlaylistSources = map[string]bool{
|
||||
"for_you": true,
|
||||
"discover": true,
|
||||
}
|
||||
|
||||
// RecordPlayStartedWithSource is RecordPlayStarted plus a `source`
|
||||
// tag identifying which surface the play came from. When source is a
|
||||
// known system-playlist kind the track is appended to that user's
|
||||
// rotation set so shuffle-on-play can prefer the unplayed tail on a
|
||||
// re-play within the day. Empty source → behaves exactly like the
|
||||
// pre-#415 path (NULL column, no rotation write).
|
||||
func (w *Writer) RecordPlayStartedWithSource(
|
||||
ctx context.Context,
|
||||
userID, trackID pgtype.UUID,
|
||||
clientID, source string,
|
||||
at time.Time,
|
||||
) (StartedResult, error) {
|
||||
var out StartedResult
|
||||
err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
|
||||
@@ -78,12 +102,17 @@ func (w *Writer) RecordPlayStarted(
|
||||
if clientID != "" {
|
||||
clientIDPtr = &clientID
|
||||
}
|
||||
var sourcePtr *string
|
||||
if source != "" {
|
||||
sourcePtr = &source
|
||||
}
|
||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -95,6 +124,16 @@ func (w *Writer) RecordPlayStarted(
|
||||
if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if systemPlaylistSources[source] {
|
||||
if err := q.AppendRotationPlayed(ctx, dbq.AppendRotationPlayedParams{
|
||||
UserID: userID,
|
||||
PlaylistKind: source,
|
||||
TrackID: trackID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
|
||||
@@ -372,3 +372,52 @@ func TestRecordPlayEnded_SubThreshold_NoEnqueue(t *testing.T) {
|
||||
t.Errorf("expected 0 queue rows for sub-threshold play, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPlayStartedWithSource_AppendsRotation(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
now := time.Now().UTC()
|
||||
if _, err := f.w.RecordPlayStartedWithSource(
|
||||
context.Background(), f.user, f.track, "c", "for_you", now,
|
||||
); err != nil {
|
||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||
}
|
||||
st, err := f.q.GetRotationState(context.Background(), dbq.GetRotationStateParams{
|
||||
UserID: f.user, PlaylistKind: "for_you",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetRotationState: %v", err)
|
||||
}
|
||||
if len(st.PlayedTrackIds) != 1 || st.PlayedTrackIds[0] != f.track {
|
||||
t.Errorf("played_track_ids = %v, want [%v]", st.PlayedTrackIds, f.track)
|
||||
}
|
||||
|
||||
// Re-playing the same track stays a set (no duplicate append).
|
||||
if _, err := f.w.RecordPlayStartedWithSource(
|
||||
context.Background(), f.user, f.track, "c", "for_you", now.Add(time.Minute),
|
||||
); err != nil {
|
||||
t.Fatalf("RecordPlayStartedWithSource 2: %v", err)
|
||||
}
|
||||
st, err = f.q.GetRotationState(context.Background(), dbq.GetRotationStateParams{
|
||||
UserID: f.user, PlaylistKind: "for_you",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetRotationState 2: %v", err)
|
||||
}
|
||||
if len(st.PlayedTrackIds) != 1 {
|
||||
t.Errorf("played_track_ids should dedupe, got %v", st.PlayedTrackIds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPlayStarted_NoRotationWithoutSource(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
now := time.Now().UTC()
|
||||
if _, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", now); err != nil {
|
||||
t.Fatalf("RecordPlayStarted: %v", err)
|
||||
}
|
||||
_, err := f.q.GetRotationState(context.Background(), dbq.GetRotationStateParams{
|
||||
UserID: f.user, PlaylistKind: "for_you",
|
||||
})
|
||||
if err == nil {
|
||||
t.Errorf("expected no rotation row for a source-less play")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user