Files
minstrel/internal/playsessions/service.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:15:05 -04:00

72 lines
2.2 KiB
Go

// Package playsessions implements the listening-session lifecycle: a session is a contiguous sequence of plays by a user with no
// gap longer than the configured timeout (default 30 minutes) between
// events.
package playsessions
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// FindOrCreate returns the session id for the given user and event time.
// If the user's most recent session was last touched within `timeout`, that
// session is extended (last_event_at and track_count updated). Otherwise a
// new session is inserted.
//
// q is *dbq.Queries; pass either the pool-level Queries or one bound to a
// transaction so callers can compose this into a larger atomic operation.
func FindOrCreate(
ctx context.Context,
q *dbq.Queries,
userID pgtype.UUID,
eventTime time.Time,
clientID string,
timeout time.Duration,
) (pgtype.UUID, error) {
prev, err := q.GetMostRecentPlaySessionForUser(ctx, userID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return pgtype.UUID{}, err
}
if err == nil {
// "Within window" includes the exact-boundary case: gap <= timeout extends.
gap := eventTime.Sub(prev.LastEventAt.Time)
if gap <= timeout {
if err := q.TouchPlaySessionLastEvent(ctx, dbq.TouchPlaySessionLastEventParams{
ID: prev.ID,
LastEventAt: pgtype.Timestamptz{Time: eventTime, Valid: true},
}); err != nil {
return pgtype.UUID{}, err
}
return prev.ID, nil
}
}
// Either no prior session, or the gap exceeded the timeout.
var clientIDPtr *string
if clientID != "" {
clientIDPtr = &clientID
}
row, err := q.InsertPlaySession(ctx, dbq.InsertPlaySessionParams{
UserID: userID,
StartedAt: pgtype.Timestamptz{Time: eventTime, Valid: true},
ClientID: clientIDPtr,
})
if err != nil {
return pgtype.UUID{}, err
}
// Bump track_count to 1 on creation so the contract "every FindOrCreate is
// the start of one play" stays true (insert leaves track_count default 0).
if err := q.TouchPlaySessionLastEvent(ctx, dbq.TouchPlaySessionLastEventParams{
ID: row.ID,
LastEventAt: pgtype.Timestamptz{Time: eventTime, Valid: true},
}); err != nil {
return pgtype.UUID{}, err
}
return row.ID, nil
}