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:
2026-05-15 07:40:17 -04:00
parent 69569a5c2b
commit d2a0b7d780
10 changed files with 279 additions and 10 deletions
+49
View File
@@ -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")
}
}