// Package coplay builds household co-play similarity edges (#1533, milestone // #160 Opt 5). Minstrel is a single shared-library, multi-user server (no // per-user library ACL), so the "household" is the whole instance's user set. // A periodic worker recomputes artist–artist co-occurrence from play_events // entirely in SQL (no external API) and stores the edges in artist_similarity // under source='user_cooccurrence' — the pre-provisioned co-occurrence source // from the 0009 schema — which the radio/mix candidate pool consumes as a // collaborative arm. Single-user servers produce no edges (the >= 2 co-player // gate), so the feature is a graceful no-op there. package coplay import ( "context" "log/slog" "time" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // minJaccard is the co-play edge floor: prune weak pairs and self-limit hub // artists, whose large combined-audience denominators pull their Jaccard down. // 0.1 keeps only pairs whose co-player overlap is a real fraction of their // combined audience. const minJaccard = 0.1 // Worker periodically rebuilds the household co-play edge set. Pure local SQL, // so there are no rate limits — but the pair self-join is roughly // O(users × artists_per_user²), so it ticks slowly: co-listening shifts over // days, not minutes, and a frequent rebuild would burn CPU for no fresher // signal. type Worker struct { pool *pgxpool.Pool logger *slog.Logger tick time.Duration } // NewWorker constructs a worker with the production default 6h tick. func NewWorker(pool *pgxpool.Pool, logger *slog.Logger) *Worker { return &Worker{pool: pool, logger: logger, tick: 6 * time.Hour} } // Run rebuilds once at startup (so edges exist without waiting a full tick), // then every w.tick until ctx is cancelled. Runs in its own goroutine, so a // slow initial rebuild never blocks boot. func (w *Worker) Run(ctx context.Context) { if err := w.rebuild(ctx); err != nil { w.logger.Error("coplay: initial rebuild failed", "err", err) } t := time.NewTicker(w.tick) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: if err := w.rebuild(ctx); err != nil { w.logger.Error("coplay: rebuild failed", "err", err) } } } } // rebuild atomic-replaces the user_cooccurrence edge set inside one // transaction — delete the old edges, recompute from current play history — so // the candidate pool never observes a half-built set. func (w *Worker) rebuild(ctx context.Context) error { tx, err := w.pool.Begin(ctx) if err != nil { return err } defer func() { _ = tx.Rollback(ctx) }() q := dbq.New(tx) if err := q.DeleteArtistCoplayEdges(ctx); err != nil { return err } if err := q.InsertArtistCoplayEdges(ctx, minJaccard); err != nil { return err } if err := tx.Commit(ctx); err != nil { return err } w.logger.Debug("coplay: edges rebuilt") return nil }