5faa57634b
The #1249 mechanism (stamp WHY a track is in the snapshot at build time, freeze it onto the play at ingestion, break it down in metrics) generalizes from a For You one-off to the standard for every system mix (#1270): - Migration 0039 widens both pick_kind CHECKs (drop + re-add in the same change) to taste/fresh + Discover's dormant/cross_user/random + tier1-3 for the rule-#131 eligibility ladders. - GetForYouPickKindForTrack becomes GetSystemPickKindForTrack (user, variant, track); ingestion stamps any systemPlaylistSources play from its own variant's live snapshot, live + offline paths. - Discover stamps its candidate bucket on discoverTrack before the interleave, making the 40/30/30 allocation measurable; dedup keeps the taking bucket's stamp. - Metrics replace the for_you special-case with one pick-kind vocabulary — any family with attributed plays gets a breakdown, future stamping mixes need no metrics change. - Web: breakdown sub-rows are now toggled per surface (collapsed by default) so eight stamping mixes don't swamp the card. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
597 lines
19 KiB
Go
597 lines
19 KiB
Go
// Package playevents owns the four write paths into play_events / skip_events:
|
|
// RecordPlayStarted, RecordPlayEnded, RecordPlaySkipped, and
|
|
// RecordSyntheticCompletedPlay (used by the Subsonic /rest/scrobble shim).
|
|
//
|
|
// All paths run inside a single transaction. The auto-close-prior step on
|
|
// RecordPlayStarted bounds each user to at most one open play_event at any
|
|
// time (per spec data-flow section).
|
|
package playevents
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playsessions"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
|
)
|
|
|
|
type Writer struct {
|
|
pool *pgxpool.Pool
|
|
logger *slog.Logger
|
|
sessionTimeout time.Duration
|
|
skipMaxCompletionRatio float64
|
|
skipMaxDurationPlayedMs int32
|
|
}
|
|
|
|
// NewWriter constructs a writer. timeout is the play-session window; the two
|
|
// remaining args are the skip rule thresholds from spec §6.
|
|
func NewWriter(
|
|
pool *pgxpool.Pool,
|
|
logger *slog.Logger,
|
|
sessionTimeout time.Duration,
|
|
skipMaxCompletionRatio float64,
|
|
skipMaxDurationPlayedMs int,
|
|
) *Writer {
|
|
return &Writer{
|
|
pool: pool,
|
|
logger: logger,
|
|
sessionTimeout: sessionTimeout,
|
|
skipMaxCompletionRatio: skipMaxCompletionRatio,
|
|
skipMaxDurationPlayedMs: int32(skipMaxDurationPlayedMs),
|
|
}
|
|
}
|
|
|
|
type StartedResult struct {
|
|
PlayEventID pgtype.UUID
|
|
SessionID pgtype.UUID
|
|
}
|
|
|
|
// 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. 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). Mirrors the
|
|
// `playlists_kind_variant_consistent` CHECK in migration
|
|
// 0028_discovery_mix_variants.up.sql — every variant in that CHECK
|
|
// list that ships as a refreshable system mix needs to be here so
|
|
// the rotation reporter sees Android + web plays from that surface.
|
|
//
|
|
// Drift #563: this map drifted behind the migrations. It had only
|
|
// for_you + discover but migrations 0021 + 0028 added 6 more
|
|
// variants. Plays from Rediscover / Deep Cuts / Songs Like X /
|
|
// New for You / On This Day / First Listens didn't advance the
|
|
// per-user rotation, so "unplayed first" ordering staled on those
|
|
// mixes — same tracks kept surfacing.
|
|
//
|
|
// Future variant additions should update this map AND the CHECK in
|
|
// the matching migration in the same change; the
|
|
// `db_check_constraint_for_new_variants` standing rule covers the
|
|
// CHECK half.
|
|
var systemPlaylistSources = map[string]bool{
|
|
"for_you": true,
|
|
"discover": true,
|
|
"deep_cuts": true,
|
|
"rediscover": true,
|
|
"new_for_you": true,
|
|
"on_this_day": true,
|
|
"first_listens": true,
|
|
"songs_like_artist": true,
|
|
}
|
|
|
|
// lookupSystemPickKind resolves a system-playlist play's pick_kind
|
|
// (taste/fresh, Discover's bucket, or a rule-#131 tier) from the user's
|
|
// live snapshot of that variant at ingestion time. Attribution must be
|
|
// frozen now — snapshots rebuild daily, so it can't be reconstructed at
|
|
// metrics-read time (#1249, generalized in #1270). Source strings
|
|
// double as system_variant values (see systemPlaylistSources). Returns
|
|
// nil (unattributed) when the track isn't in the current snapshot —
|
|
// plays predating the feature, or an offline replay arriving after the
|
|
// track rotated out — and when the variant doesn't stamp yet. Real DB
|
|
// errors propagate and fail the caller's transaction.
|
|
func lookupSystemPickKind(
|
|
ctx context.Context,
|
|
q *dbq.Queries,
|
|
userID, trackID pgtype.UUID,
|
|
variant string,
|
|
) (*string, error) {
|
|
kind, err := q.GetSystemPickKindForTrack(ctx, dbq.GetSystemPickKindForTrackParams{
|
|
UserID: userID,
|
|
SystemVariant: &variant,
|
|
TrackID: trackID,
|
|
})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return kind, nil
|
|
}
|
|
|
|
// 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 {
|
|
q := dbq.New(tx)
|
|
if err := w.autoClosePriorOpen(ctx, q, userID, at); err != nil {
|
|
return err
|
|
}
|
|
sessionID, err := playsessions.FindOrCreate(ctx, q, userID, at, clientID, w.sessionTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var clientIDPtr *string
|
|
if clientID != "" {
|
|
clientIDPtr = &clientID
|
|
}
|
|
var sourcePtr *string
|
|
if source != "" {
|
|
sourcePtr = &source
|
|
}
|
|
var pickKind *string
|
|
if systemPlaylistSources[source] {
|
|
pickKind, err = lookupSystemPickKind(ctx, q, userID, trackID, source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
|
UserID: userID,
|
|
TrackID: trackID,
|
|
SessionID: sessionID,
|
|
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
ClientID: clientIDPtr,
|
|
Source: sourcePtr,
|
|
PickKind: pickKind,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out.PlayEventID = ev.ID
|
|
out.SessionID = sessionID
|
|
|
|
// Capture session vector for the just-inserted row.
|
|
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
|
|
}
|
|
|
|
// RecordPlayEnded closes a play_event with duration + computed completion
|
|
// ratio. Applies the spec §6 skip rule (AND of completion < threshold AND
|
|
// duration_played < threshold) — both conditions must hold for the row to
|
|
// be marked was_skipped=true. If skipped, also writes a skip_events row.
|
|
func (w *Writer) RecordPlayEnded(
|
|
ctx context.Context,
|
|
playEventID pgtype.UUID,
|
|
durationPlayedMs int32,
|
|
at time.Time,
|
|
) error {
|
|
if err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
|
|
q := dbq.New(tx)
|
|
ev, err := q.GetPlayEventByID(ctx, playEventID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
track, err := q.GetTrackByID(ctx, ev.TrackID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Idempotency for the client's durable close-by-id replay: if the
|
|
// row is already closed, re-applying the close is harmless (the
|
|
// UPDATE is last-write-wins) but a second skip_events insert must
|
|
// NOT fire — a lost-response retry would otherwise double the
|
|
// skip signal. See the 2026-06-11 contract audit.
|
|
alreadyEnded := ev.EndedAt.Valid
|
|
ratio := 0.0
|
|
if track.DurationMs > 0 {
|
|
ratio = float64(durationPlayedMs) / float64(track.DurationMs)
|
|
}
|
|
isSkip := ratio < w.skipMaxCompletionRatio && durationPlayedMs < w.skipMaxDurationPlayedMs
|
|
ratioPtr := ratio
|
|
durPtr := durationPlayedMs
|
|
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
|
|
ID: playEventID,
|
|
EndedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
DurationPlayedMs: &durPtr,
|
|
CompletionRatio: &ratioPtr,
|
|
WasSkipped: isSkip,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if isSkip && !alreadyEnded {
|
|
if _, err := q.InsertSkipEvent(ctx, dbq.InsertSkipEventParams{
|
|
UserID: ev.UserID,
|
|
TrackID: ev.TrackID,
|
|
SessionID: ev.SessionID,
|
|
SkippedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
PositionMs: durationPlayedMs,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
// Post-commit best-effort scrobble enqueue. Errors logged + swallowed:
|
|
// scrobble delivery is enrichment, not a precondition for canonical play
|
|
// history.
|
|
if err := scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID); err != nil {
|
|
w.logger.Warn("playevents: scrobble enqueue failed", "play_event_id", playEventID, "err", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RecordPlaySkipped is the user-initiated-skip variant: was_skipped is forced
|
|
// true regardless of the rule, and a skip_events row is always written.
|
|
func (w *Writer) RecordPlaySkipped(
|
|
ctx context.Context,
|
|
playEventID pgtype.UUID,
|
|
positionMs int32,
|
|
at time.Time,
|
|
) error {
|
|
return pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
|
|
q := dbq.New(tx)
|
|
ev, err := q.GetPlayEventByID(ctx, playEventID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
track, err := q.GetTrackByID(ctx, ev.TrackID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ratio := 0.0
|
|
if track.DurationMs > 0 {
|
|
ratio = float64(positionMs) / float64(track.DurationMs)
|
|
}
|
|
ratioPtr := ratio
|
|
durPtr := positionMs
|
|
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
|
|
ID: playEventID,
|
|
EndedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
DurationPlayedMs: &durPtr,
|
|
CompletionRatio: &ratioPtr,
|
|
WasSkipped: true,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
_, err = q.InsertSkipEvent(ctx, dbq.InsertSkipEventParams{
|
|
UserID: ev.UserID,
|
|
TrackID: ev.TrackID,
|
|
SessionID: ev.SessionID,
|
|
SkippedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
PositionMs: positionMs,
|
|
})
|
|
return err
|
|
})
|
|
}
|
|
|
|
// RecordSyntheticCompletedPlay writes a start+end pair for a Subsonic
|
|
// scrobble?submission=true call. ended_at = at + track.duration_ms,
|
|
// duration_played_ms = track.duration_ms, was_skipped = false.
|
|
func (w *Writer) RecordSyntheticCompletedPlay(
|
|
ctx context.Context,
|
|
userID, trackID pgtype.UUID,
|
|
clientID string,
|
|
at time.Time,
|
|
) error {
|
|
var playEventID pgtype.UUID
|
|
if err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
|
|
q := dbq.New(tx)
|
|
track, err := q.GetTrackByID(ctx, trackID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := w.autoClosePriorOpen(ctx, q, userID, at); err != nil {
|
|
return err
|
|
}
|
|
sessionID, err := playsessions.FindOrCreate(ctx, q, userID, at, clientID, w.sessionTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var clientIDPtr *string
|
|
if clientID != "" {
|
|
clientIDPtr = &clientID
|
|
}
|
|
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
|
UserID: userID,
|
|
TrackID: trackID,
|
|
SessionID: sessionID,
|
|
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
ClientID: clientIDPtr,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
playEventID = ev.ID
|
|
if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil {
|
|
return err
|
|
}
|
|
ratio := 1.0
|
|
dur := track.DurationMs
|
|
_, err = q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
|
|
ID: ev.ID,
|
|
EndedAt: pgtype.Timestamptz{Time: at.Add(time.Duration(track.DurationMs) * time.Millisecond), Valid: true},
|
|
DurationPlayedMs: &dur,
|
|
CompletionRatio: &ratio,
|
|
WasSkipped: false,
|
|
})
|
|
return err
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
// Post-commit best-effort scrobble enqueue. Errors logged + swallowed:
|
|
// scrobble delivery is enrichment, not a precondition for canonical play
|
|
// history.
|
|
if err := scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID); err != nil {
|
|
w.logger.Warn("playevents: scrobble enqueue failed", "play_event_id", playEventID, "err", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RecordOfflinePlay writes a complete start+end play in one call from
|
|
// a caller-supplied timestamp + duration. This is the replay path for
|
|
// the Flutter offline mutation queue (#426 part B): a play that
|
|
// happened with no/flaky connectivity is captured locally as a
|
|
// completed unit and replayed here once back online, preserving the
|
|
// original `at`.
|
|
//
|
|
// Generalizes RecordSyntheticCompletedPlay: instead of assuming full
|
|
// completion it applies the spec §6 skip rule from the real
|
|
// durationPlayed vs track length (same AND-of-thresholds as
|
|
// RecordPlayEnded), and threads `source` so #415 rotation advances
|
|
// for system-playlist plays just like the live path.
|
|
//
|
|
// durationPlayedMs is clamped to [0, track duration] to absorb
|
|
// client clock / position drift.
|
|
func (w *Writer) RecordOfflinePlay(
|
|
ctx context.Context,
|
|
userID, trackID pgtype.UUID,
|
|
clientID, source string,
|
|
at time.Time,
|
|
durationPlayedMs int32,
|
|
) error {
|
|
var playEventID pgtype.UUID
|
|
if err := pgx.BeginFunc(ctx, w.pool, func(tx pgx.Tx) error {
|
|
q := dbq.New(tx)
|
|
track, err := q.GetTrackByID(ctx, trackID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Idempotency: the offline queue is at-least-once, so a response lost
|
|
// after commit makes the client re-fire the same play. The replayed
|
|
// payload carries the original `at`, so an exact (user, track,
|
|
// started_at) match means we already recorded it — no-op instead of a
|
|
// duplicate history row. See the 2026-06-11 contract audit.
|
|
var dupID pgtype.UUID
|
|
err = tx.QueryRow(ctx,
|
|
`SELECT id FROM play_events
|
|
WHERE user_id = $1 AND track_id = $2 AND started_at = $3
|
|
LIMIT 1`,
|
|
userID, trackID, pgtype.Timestamptz{Time: at, Valid: true},
|
|
).Scan(&dupID)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !errors.Is(err, pgx.ErrNoRows) {
|
|
return err
|
|
}
|
|
if err := w.autoClosePriorOpen(ctx, q, userID, at); err != nil {
|
|
return err
|
|
}
|
|
sessionID, err := playsessions.FindOrCreate(ctx, q, userID, at, clientID, w.sessionTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var clientIDPtr *string
|
|
if clientID != "" {
|
|
clientIDPtr = &clientID
|
|
}
|
|
var sourcePtr *string
|
|
if source != "" {
|
|
sourcePtr = &source
|
|
}
|
|
var pickKind *string
|
|
if systemPlaylistSources[source] {
|
|
pickKind, err = lookupSystemPickKind(ctx, q, userID, trackID, source)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
|
UserID: userID,
|
|
TrackID: trackID,
|
|
SessionID: sessionID,
|
|
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
ClientID: clientIDPtr,
|
|
Source: sourcePtr,
|
|
PickKind: pickKind,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
playEventID = ev.ID
|
|
if err := w.captureSessionVector(ctx, q, ev.ID, sessionID, at); err != nil {
|
|
return err
|
|
}
|
|
|
|
played := durationPlayedMs
|
|
if played < 0 {
|
|
played = 0
|
|
}
|
|
if track.DurationMs > 0 && played > track.DurationMs {
|
|
played = track.DurationMs
|
|
}
|
|
ratio := 0.0
|
|
if track.DurationMs > 0 {
|
|
ratio = float64(played) / float64(track.DurationMs)
|
|
}
|
|
isSkip := ratio < w.skipMaxCompletionRatio && played < w.skipMaxDurationPlayedMs
|
|
ratioPtr := ratio
|
|
durPtr := played
|
|
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
|
|
ID: ev.ID,
|
|
EndedAt: pgtype.Timestamptz{Time: at.Add(time.Duration(played) * time.Millisecond), Valid: true},
|
|
DurationPlayedMs: &durPtr,
|
|
CompletionRatio: &ratioPtr,
|
|
WasSkipped: isSkip,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if isSkip {
|
|
if _, err := q.InsertSkipEvent(ctx, dbq.InsertSkipEventParams{
|
|
UserID: userID,
|
|
TrackID: trackID,
|
|
SessionID: sessionID,
|
|
SkippedAt: pgtype.Timestamptz{Time: at.Add(time.Duration(played) * time.Millisecond), Valid: true},
|
|
PositionMs: played,
|
|
}); 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
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := scrobble.MaybeEnqueue(ctx, dbq.New(w.pool), playEventID); err != nil {
|
|
w.logger.Warn("playevents: scrobble enqueue failed", "play_event_id", playEventID, "err", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// captureSessionVector queries the user's prior plays in the given session
|
|
// (before `at`), builds the session vector, and UPDATEs the just-inserted
|
|
// play_event with it. Runs inside the caller's transaction (q is the
|
|
// transaction-bound *dbq.Queries). Errors here propagate — vector capture
|
|
// is best-effort, but DB errors should fail the transaction.
|
|
func (w *Writer) captureSessionVector(
|
|
ctx context.Context,
|
|
q *dbq.Queries,
|
|
playEventID, sessionID pgtype.UUID,
|
|
at time.Time,
|
|
) error {
|
|
priorTracks, err := q.ListRecentSessionTracks(ctx, dbq.ListRecentSessionTracksParams{
|
|
SessionID: sessionID,
|
|
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
Limit: 5,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
vec := recommendation.BuildSessionVector(priorTracks)
|
|
vecJSON, err := json.Marshal(vec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return q.UpdatePlayEventVector(ctx, dbq.UpdatePlayEventVectorParams{
|
|
ID: playEventID,
|
|
SessionVectorAtPlay: vecJSON,
|
|
})
|
|
}
|
|
|
|
// autoClosePriorOpen closes any open (ended_at IS NULL) play_event for the
|
|
// user. Sets ended_at = at and duration_played_ms = min(at - started_at,
|
|
// track duration) — our best estimate of how long the orphan played when
|
|
// the client never sent play_ended (e.g. backgrounded mid-track). The same
|
|
// skip rule as RecordPlayEnded is then applied to that estimate: an orphan
|
|
// that sat open past the track's length reads as ratio ~1 → a real play, so
|
|
// a fully-listened track that lost its close still lands in history instead
|
|
// of being force-hidden. Deliberately writes no skip_events row — an
|
|
// auto-close is an ambiguous signal, not a deliberate skip, and must not
|
|
// feed the skip-ratio / recommendation signal.
|
|
func (w *Writer) autoClosePriorOpen(
|
|
ctx context.Context,
|
|
q *dbq.Queries,
|
|
userID pgtype.UUID,
|
|
at time.Time,
|
|
) error {
|
|
prior, err := q.GetOpenPlayEventForUser(ctx, userID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
track, err := q.GetTrackByID(ctx, prior.TrackID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
elapsedMs := int32(at.Sub(prior.StartedAt.Time) / time.Millisecond)
|
|
if elapsedMs < 0 {
|
|
elapsedMs = 0
|
|
}
|
|
if elapsedMs > track.DurationMs && track.DurationMs > 0 {
|
|
elapsedMs = track.DurationMs
|
|
}
|
|
ratio := 0.0
|
|
if track.DurationMs > 0 {
|
|
ratio = float64(elapsedMs) / float64(track.DurationMs)
|
|
}
|
|
isSkip := ratio < w.skipMaxCompletionRatio && elapsedMs < w.skipMaxDurationPlayedMs
|
|
ratioPtr := ratio
|
|
durPtr := elapsedMs
|
|
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
|
|
ID: prior.ID,
|
|
EndedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
|
DurationPlayedMs: &durPtr,
|
|
CompletionRatio: &ratioPtr,
|
|
WasSkipped: isSkip,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
w.logger.Info("playevents: auto-closed prior open row",
|
|
"play_event_id", prior.ID, "elapsed_ms", elapsedMs, "was_skipped", isSkip)
|
|
return nil
|
|
}
|