Files
memby/server/internal/store/analytics.go
T

193 lines
5.9 KiB
Go

package store
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// BrowsingCandidates returns library items the user actively focused or selected,
// strongest first. Impressions are intentionally excluded: merely scrolling past a row
// is not evidence of taste.
func (s *Store) BrowsingCandidates(
ctx context.Context,
userID string,
since time.Time,
limit int,
) ([]json.RawMessage, error) {
rows, err := s.pool.Query(ctx, `
SELECT li.payload
FROM row_events re
JOIN library_items li ON li.id = re.item_id
WHERE re.emby_user_id = $1
AND re.occurred_at >= $2
AND re.event IN ('focus', 'select')
GROUP BY li.id, li.payload
ORDER BY
count(*) FILTER (WHERE re.event = 'select') * 20 +
count(*) FILTER (WHERE re.event = 'focus') * 2 +
coalesce(sum(re.dwell_ms), 0) / 10000 DESC,
max(re.occurred_at) DESC
LIMIT $3`, userID, since, limit)
if err != nil {
return nil, fmt.Errorf("store: browsing candidates: %w", err)
}
defer rows.Close()
out := []json.RawMessage{}
for rows.Next() {
var payload []byte
if err := rows.Scan(&payload); err != nil {
return nil, err
}
out = append(out, json.RawMessage(payload))
}
return out, rows.Err()
}
// RowEvent is one reported interaction with a home-screen row.
type RowEvent struct {
OccurredAt time.Time
UserID string
RowID string
RowKind string
Event string
ItemID string
DwellMs int
}
// Event kinds. Impressions say a row was drawn; focus says the remote actually landed
// on it and for how long; select says something was opened from it.
const (
RowEventImpression = "impression"
RowEventFocus = "focus"
RowEventSelect = "select"
)
// RowStat is the aggregate the admin page renders.
type RowStat struct {
RowID string `json:"rowId"`
RowKind string `json:"rowKind"`
Impressions int64 `json:"impressions"`
Focuses int64 `json:"focuses"`
Selects int64 `json:"selects"`
DwellMs int64 `json:"dwellMs"`
Viewers int64 `json:"viewers"`
SelectRate float64 `json:"selectRate"`
}
// UserRowStats is the per-profile counterpart to the admin aggregate. It gives the
// home composer enough evidence to gently demote shelves that a viewer repeatedly
// passes over without turning a couple of accidental focus moves into a preference.
func (s *Store) UserRowStats(
ctx context.Context,
userID string,
since time.Time,
) ([]RowStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT row_id,
(array_agg(row_kind ORDER BY occurred_at DESC))[1] AS row_kind,
count(*) FILTER (WHERE event = 'impression') AS impressions,
count(*) FILTER (WHERE event = 'focus') AS focuses,
count(*) FILTER (WHERE event = 'select') AS selects,
coalesce(sum(dwell_ms), 0) AS dwell_ms
FROM row_events
WHERE emby_user_id = $1 AND occurred_at >= $2
GROUP BY row_id`, userID, since)
if err != nil {
return nil, fmt.Errorf("store: user row stats: %w", err)
}
defer rows.Close()
stats := []RowStat{}
for rows.Next() {
var stat RowStat
if err := rows.Scan(
&stat.RowID, &stat.RowKind, &stat.Impressions, &stat.Focuses,
&stat.Selects, &stat.DwellMs,
); err != nil {
return nil, err
}
stat.Viewers = 1
if stat.Impressions > 0 {
stat.SelectRate = float64(stat.Selects) / float64(stat.Impressions)
}
stats = append(stats, stat)
}
return stats, rows.Err()
}
func (s *Store) InsertRowEvents(ctx context.Context, events []RowEvent) error {
if len(events) == 0 {
return nil
}
batch := &pgx.Batch{}
for _, event := range events {
batch.Queue(`
INSERT INTO row_events (occurred_at, emby_user_id, row_id, row_kind, event, item_id, dwell_ms)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
event.OccurredAt, event.UserID, event.RowID, event.RowKind,
event.Event, event.ItemID, event.DwellMs)
}
results := s.pool.SendBatch(ctx, batch)
defer results.Close()
for range events {
if _, err := results.Exec(); err != nil {
return fmt.Errorf("store: insert row events: %w", err)
}
}
return nil
}
// RowStats aggregates engagement since a point in time, busiest row first.
//
// Dwell is the interesting number: impressions only say a row was on screen, whereas
// dwell says someone actually stopped there.
func (s *Store) RowStats(ctx context.Context, since time.Time) ([]RowStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT row_id,
(array_agg(row_kind ORDER BY occurred_at DESC))[1] AS row_kind,
count(*) FILTER (WHERE event = 'impression') AS impressions,
count(*) FILTER (WHERE event = 'focus') AS focuses,
count(*) FILTER (WHERE event = 'select') AS selects,
coalesce(sum(dwell_ms), 0) AS dwell_ms,
count(DISTINCT emby_user_id) AS viewers
FROM row_events
WHERE occurred_at >= $1
GROUP BY row_id
ORDER BY dwell_ms DESC, impressions DESC`, since)
if err != nil {
return nil, fmt.Errorf("store: row stats: %w", err)
}
defer rows.Close()
stats := []RowStat{}
for rows.Next() {
var stat RowStat
if err := rows.Scan(&stat.RowID, &stat.RowKind, &stat.Impressions, &stat.Focuses,
&stat.Selects, &stat.DwellMs, &stat.Viewers); err != nil {
return nil, err
}
if stat.Impressions > 0 {
stat.SelectRate = float64(stat.Selects) / float64(stat.Impressions)
}
stats = append(stats, stat)
}
return stats, rows.Err()
}
// PruneRowEvents drops raw events past their retention window. Aggregates are computed
// at read time, so nothing is preserved once the events go — which is the point: this is
// engagement telemetry for tuning rows, not a permanent record of what people watched.
func (s *Store) PruneRowEvents(ctx context.Context, olderThan time.Duration) (int64, error) {
tag, err := s.pool.Exec(ctx,
`DELETE FROM row_events WHERE occurred_at < now() - $1::interval`,
fmt.Sprintf("%d seconds", int64(olderThan.Seconds())))
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}