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
+12 -6
View File
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
}
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at FROM play_events
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events
WHERE user_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC
LIMIT 1
@@ -78,12 +78,13 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
&i.ClientID,
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
&i.Source,
)
return i, err
}
const getPlayEventByID = `-- name: GetPlayEventByID :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at FROM play_events WHERE id = $1
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events WHERE id = $1
`
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
@@ -102,15 +103,16 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
&i.ClientID,
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
&i.Source,
)
return i, err
}
const insertPlayEvent = `-- name: InsertPlayEvent :one
INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id
) VALUES ($1, $2, $3, $4, $5)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at
user_id, track_id, session_id, started_at, client_id, source
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
`
type InsertPlayEventParams struct {
@@ -119,6 +121,7 @@ type InsertPlayEventParams struct {
SessionID pgtype.UUID
StartedAt pgtype.Timestamptz
ClientID *string
Source *string
}
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
@@ -128,6 +131,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
arg.SessionID,
arg.StartedAt,
arg.ClientID,
arg.Source,
)
var i PlayEvent
err := row.Scan(
@@ -143,6 +147,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
&i.ClientID,
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
&i.Source,
)
return i, err
}
@@ -286,7 +291,7 @@ SET ended_at = $2,
completion_ratio = $4,
was_skipped = $5
WHERE id = $1
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
`
type UpdatePlayEventEndedParams struct {
@@ -322,6 +327,7 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
&i.ClientID,
&i.SessionVectorAtPlay,
&i.ScrobbledAt,
&i.Source,
)
return i, err
}
+9
View File
@@ -369,6 +369,7 @@ type PlayEvent struct {
ClientID *string
SessionVectorAtPlay []byte
ScrobbledAt pgtype.Timestamptz
Source *string
}
type PlaySession struct {
@@ -475,6 +476,14 @@ type SmtpConfig struct {
UpdatedAt pgtype.Timestamptz
}
type SystemPlaylistRotationState struct {
UserID pgtype.UUID
PlaylistKind string
PlayedTrackIds []pgtype.UUID
RotationStartedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type SystemPlaylistRun struct {
UserID pgtype.UUID
LastRunAt pgtype.Timestamptz
@@ -0,0 +1,91 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: system_playlist_rotation.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const appendRotationPlayed = `-- name: AppendRotationPlayed :exec
INSERT INTO system_playlist_rotation_state (
user_id, playlist_kind, played_track_ids, rotation_started_at, updated_at
) VALUES (
$1, $2,
array_append('{}'::uuid[], $3::uuid), now(), now()
)
ON CONFLICT (user_id, playlist_kind) DO UPDATE
SET played_track_ids =
CASE WHEN $3::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, $3::uuid)
END,
updated_at = now()
`
type AppendRotationPlayedParams struct {
UserID pgtype.UUID
PlaylistKind string
TrackID pgtype.UUID
}
// 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.
// 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.
func (q *Queries) AppendRotationPlayed(ctx context.Context, arg AppendRotationPlayedParams) error {
_, err := q.db.Exec(ctx, appendRotationPlayed, arg.UserID, arg.PlaylistKind, arg.TrackID)
return err
}
const getRotationState = `-- name: GetRotationState :one
SELECT user_id, playlist_kind, played_track_ids, rotation_started_at, updated_at FROM system_playlist_rotation_state
WHERE user_id = $1 AND playlist_kind = $2
`
type GetRotationStateParams struct {
UserID pgtype.UUID
PlaylistKind string
}
func (q *Queries) GetRotationState(ctx context.Context, arg GetRotationStateParams) (SystemPlaylistRotationState, error) {
row := q.db.QueryRow(ctx, getRotationState, arg.UserID, arg.PlaylistKind)
var i SystemPlaylistRotationState
err := row.Scan(
&i.UserID,
&i.PlaylistKind,
&i.PlayedTrackIds,
&i.RotationStartedAt,
&i.UpdatedAt,
)
return i, err
}
const resetRotationState = `-- name: ResetRotationState :exec
UPDATE system_playlist_rotation_state
SET played_track_ids = '{}',
rotation_started_at = now(),
updated_at = now()
WHERE user_id = $1 AND playlist_kind = $2
`
type ResetRotationStateParams struct {
UserID pgtype.UUID
PlaylistKind string
}
// Clears the played set and advances the rotation window. Called
// when the playlist snapshot has been fully sampled (Stage 2).
func (q *Queries) ResetRotationState(ctx context.Context, arg ResetRotationStateParams) error {
_, err := q.db.Exec(ctx, resetRotationState, arg.UserID, arg.PlaylistKind)
return err
}
@@ -0,0 +1,5 @@
-- 0027_system_playlist_rotation.down.sql
DROP TABLE IF EXISTS system_playlist_rotation_state;
ALTER TABLE play_events
DROP COLUMN IF EXISTS source;
@@ -0,0 +1,26 @@
-- 0027_system_playlist_rotation.up.sql — sample-history dedup for
-- system playlists (Fable #415). Two parts:
--
-- 1. play_events.source: which system playlist a play originated
-- from ('for_you' | 'discover' | future kinds), or NULL for any
-- play not launched from a system-playlist surface (library,
-- user playlist, radio, Subsonic). Lets the ingest path know a
-- play should count against that playlist's rotation.
--
-- 2. system_playlist_rotation_state: per (user, playlist_kind) set
-- of track ids already played in the current rotation window.
-- Shuffle-on-play prefers the unplayed tail; when the snapshot
-- is exhausted the set resets and rotation_started_at advances.
-- Centralized server-side so phone / web / watch share one
-- rotation, mirroring how radio dedup already lives server-side.
ALTER TABLE play_events
ADD COLUMN source text;
CREATE TABLE system_playlist_rotation_state (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
playlist_kind text NOT NULL,
played_track_ids uuid[] NOT NULL DEFAULT '{}',
rotation_started_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, playlist_kind)
);
+2 -2
View File
@@ -25,8 +25,8 @@ LIMIT 1;
-- name: InsertPlayEvent :one
INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id
) VALUES ($1, $2, $3, $4, $5)
user_id, track_id, session_id, started_at, client_id, source
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: UpdatePlayEventEnded :one
@@ -0,0 +1,36 @@
-- 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;