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 } // JourneyEvent is one significant step through the app. All descriptive fields are // controlled vocabulary; ItemName is the only free-text content context retained. type JourneyEvent struct { ID int64 `json:"id"` OccurredAt time.Time `json:"occurredAt"` UserID string `json:"userId"` JourneyID string `json:"journeyId"` Sequence int `json:"sequence"` Category string `json:"category"` Action string `json:"action"` Screen string `json:"screen"` Feature string `json:"feature"` Source string `json:"source"` Target string `json:"target"` ItemName string `json:"itemName,omitempty"` ItemType string `json:"itemType,omitempty"` Outcome string `json:"outcome,omitempty"` } type AnalyticsUser struct { UserID string `json:"userId"` Username string `json:"username"` Events int64 `json:"events"` Journeys int64 `json:"journeys"` LastActiveAt time.Time `json:"lastActiveAt"` } type FeatureStat struct { Feature string `json:"feature"` Uses int64 `json:"uses"` LastUsedAt time.Time `json:"lastUsedAt"` } type PathStat struct { From string `json:"from"` To string `json:"to"` Count int64 `json:"count"` } // JourneyStats is the server-derived health of foreground visits in a reporting window. // An unfinished visit is only abandoned once it has been quiet for thirty minutes; until // then it is active, so an open television does not immediately look like a failed flow. type JourneyStats struct { Events int64 `json:"events"` Journeys int64 `json:"journeys"` Viewers int64 `json:"viewers"` Completed int64 `json:"completed"` Abandoned int64 `json:"abandoned"` Active int64 `json:"active"` AverageSteps float64 `json:"averageSteps"` AverageTimeMs int64 `json:"averageTimeMs"` CompletionRate float64 `json:"completionRate"` } type JourneyActionStat struct { Category string `json:"category"` Action string `json:"action"` Events int64 `json:"events"` Journeys int64 `json:"journeys"` } // 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 } func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent) error { if len(events) == 0 { return nil } batch := &pgx.Batch{} for _, event := range events { batch.Queue(` INSERT INTO journey_events (occurred_at, emby_user_id, journey_id, sequence, category, action, screen, feature, source, target, item_name, item_type, outcome) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`, event.OccurredAt, event.UserID, event.JourneyID, event.Sequence, event.Category, event.Action, event.Screen, event.Feature, event.Source, event.Target, event.ItemName, event.ItemType, event.Outcome) } results := s.pool.SendBatch(ctx, batch) defer results.Close() for range events { if _, err := results.Exec(); err != nil { return fmt.Errorf("store: insert journey events: %w", err) } } return nil } func (s *Store) AnalyticsUsers(ctx context.Context, since time.Time) ([]AnalyticsUser, error) { rows, err := s.pool.Query(ctx, ` SELECT je.emby_user_id, coalesce((array_agg(s.username ORDER BY s.last_seen_at DESC) FILTER (WHERE s.username IS NOT NULL))[1], ''), count(DISTINCT je.id), count(DISTINCT je.journey_id), max(je.occurred_at) FROM journey_events je LEFT JOIN sessions s ON s.emby_user_id = je.emby_user_id WHERE je.occurred_at >= $1 GROUP BY je.emby_user_id ORDER BY max(je.occurred_at) DESC`, since) if err != nil { return nil, fmt.Errorf("store: analytics users: %w", err) } defer rows.Close() out := []AnalyticsUser{} for rows.Next() { var value AnalyticsUser if err := rows.Scan(&value.UserID, &value.Username, &value.Events, &value.Journeys, &value.LastActiveAt); err != nil { return nil, err } out = append(out, value) } return out, rows.Err() } func (s *Store) JourneyStats(ctx context.Context, userID string, since time.Time) (JourneyStats, error) { var value JourneyStats err := s.pool.QueryRow(ctx, ` WITH visits AS ( SELECT emby_user_id, journey_id, count(*) AS steps, min(occurred_at) AS started_at, max(occurred_at) AS last_at, bool_or(action = 'journey_end') AS completed FROM journey_events WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2) GROUP BY emby_user_id, journey_id ) SELECT coalesce(sum(steps), 0), count(*), count(DISTINCT emby_user_id), count(*) FILTER (WHERE completed), count(*) FILTER (WHERE NOT completed AND last_at < now() - interval '30 minutes'), count(*) FILTER (WHERE NOT completed AND last_at >= now() - interval '30 minutes'), coalesce(avg(steps), 0)::double precision, round(coalesce(avg(extract(epoch FROM (last_at - started_at)) * 1000) FILTER (WHERE completed), 0))::bigint FROM visits`, since, userID).Scan( &value.Events, &value.Journeys, &value.Viewers, &value.Completed, &value.Abandoned, &value.Active, &value.AverageSteps, &value.AverageTimeMs, ) if err != nil { return JourneyStats{}, fmt.Errorf("store: journey stats: %w", err) } finished := value.Completed + value.Abandoned if finished > 0 { value.CompletionRate = float64(value.Completed) / float64(finished) } return value, nil } func (s *Store) JourneyActionStats(ctx context.Context, userID string, since time.Time) ([]JourneyActionStat, error) { rows, err := s.pool.Query(ctx, ` SELECT category, action, count(*), count(DISTINCT journey_id) FROM journey_events WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2) AND action NOT IN ('journey_start', 'journey_end') GROUP BY category, action ORDER BY count(*) DESC, category, action`, since, userID) if err != nil { return nil, fmt.Errorf("store: journey action stats: %w", err) } defer rows.Close() out := []JourneyActionStat{} for rows.Next() { var value JourneyActionStat if err := rows.Scan(&value.Category, &value.Action, &value.Events, &value.Journeys); err != nil { return nil, err } out = append(out, value) } return out, rows.Err() } func (s *Store) UserFeatureStats(ctx context.Context, userID string, since time.Time) ([]FeatureStat, error) { rows, err := s.pool.Query(ctx, ` SELECT feature, count(*), max(occurred_at) FROM journey_events WHERE ($1 = '' OR emby_user_id=$1) AND occurred_at >= $2 AND feature <> '' AND action NOT IN ('screen_view', 'journey_start', 'journey_end') GROUP BY feature ORDER BY count(*) DESC, feature`, userID, since) if err != nil { return nil, fmt.Errorf("store: user feature stats: %w", err) } defer rows.Close() out := []FeatureStat{} for rows.Next() { var v FeatureStat if err := rows.Scan(&v.Feature, &v.Uses, &v.LastUsedAt); err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) ([]PathStat, error) { rows, err := s.pool.Query(ctx, ` WITH ordered AS ( SELECT id, journey_id, sequence, action, occurred_at, coalesce(nullif(target,''), nullif(screen,''), feature) AS node, lag(coalesce(nullif(target,''), nullif(screen,''), feature)) OVER (PARTITION BY journey_id ORDER BY sequence, occurred_at, id) AS previous FROM journey_events WHERE ($1 = '' OR emby_user_id=$1) AND occurred_at >= $2 ), path_steps AS ( SELECT previous AS from_node, node AS to_node FROM ordered WHERE previous IS NOT NULL AND node IS NOT NULL AND previous <> node ), last_steps AS ( SELECT DISTINCT ON (journey_id) journey_id, node, action, occurred_at FROM ordered ORDER BY journey_id, sequence DESC, occurred_at DESC, id DESC ), all_steps AS ( SELECT from_node, to_node FROM path_steps UNION ALL SELECT node, 'abandoned' FROM last_steps WHERE action <> 'journey_end' AND node <> '' AND occurred_at < now() - interval '30 minutes' ) SELECT from_node, to_node, count(*) FROM all_steps GROUP BY from_node, to_node ORDER BY count(*) DESC, from_node, to_node LIMIT 20`, userID, since) if err != nil { return nil, fmt.Errorf("store: user paths: %w", err) } defer rows.Close() out := []PathStat{} for rows.Next() { var v PathStat if err := rows.Scan(&v.From, &v.To, &v.Count); err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time.Time, limit int) ([]JourneyEvent, error) { rows, err := s.pool.Query(ctx, ` SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action, screen, feature, source, target, item_name, item_type, outcome FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2 ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit) if err != nil { return nil, fmt.Errorf("store: user journey events: %w", err) } defer rows.Close() out := []JourneyEvent{} for rows.Next() { var v JourneyEvent if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemName, &v.ItemType, &v.Outcome); err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } // 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) { interval := fmt.Sprintf("%d seconds", int64(olderThan.Seconds())) rows, err := s.pool.Exec(ctx, `DELETE FROM row_events WHERE occurred_at < now() - $1::interval`, interval) if err != nil { return 0, err } journeys, err := s.pool.Exec(ctx, `DELETE FROM journey_events WHERE occurred_at < now() - $1::interval`, interval) if err != nil { return rows.RowsAffected(), err } return rows.RowsAffected() + journeys.RowsAffected(), nil }