Files
minstrel/internal/db/queries/system_playlist_rotation.sql
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

37 lines
1.6 KiB
SQL

-- Rotation state for system-playlist sample-history dedup (#415).
-- One row per (user, playlist_kind). played_track_ids accumulates
-- tracks heard from that playlist in the current rotation; shuffle
-- prefers ids NOT in this set, then resets when the snapshot is
-- exhausted.
-- name: AppendRotationPlayed :exec
-- Records that the given track was played from the (user, kind)
-- system playlist. Creates the row on first play; on conflict
-- appends only if not already present so the array stays a set.
INSERT INTO system_playlist_rotation_state (
user_id, playlist_kind, played_track_ids, rotation_started_at, updated_at
) VALUES (
sqlc.arg(user_id), sqlc.arg(playlist_kind),
array_append('{}'::uuid[], sqlc.arg(track_id)::uuid), now(), now()
)
ON CONFLICT (user_id, playlist_kind) DO UPDATE
SET played_track_ids =
CASE WHEN sqlc.arg(track_id)::uuid = ANY(system_playlist_rotation_state.played_track_ids)
THEN system_playlist_rotation_state.played_track_ids
ELSE array_append(system_playlist_rotation_state.played_track_ids, sqlc.arg(track_id)::uuid)
END,
updated_at = now();
-- name: GetRotationState :one
SELECT * FROM system_playlist_rotation_state
WHERE user_id = $1 AND playlist_kind = $2;
-- name: ResetRotationState :exec
-- Clears the played set and advances the rotation window. Called
-- when the playlist snapshot has been fully sampled (Stage 2).
UPDATE system_playlist_rotation_state
SET played_track_ids = '{}',
rotation_started_at = now(),
updated_at = now()
WHERE user_id = $1 AND playlist_kind = $2;