637 lines
23 KiB
Go
637 lines
23 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
|
|
}
|
|
|
|
// 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"`
|
|
ItemID string `json:"itemId,omitempty"`
|
|
ItemName string `json:"itemName,omitempty"`
|
|
ItemType string `json:"itemType,omitempty"`
|
|
// PlaySessionID is Emby's id for the stream a playback step is about, and is empty on
|
|
// every other kind of step. It is what joins a journey to the playback the gateway
|
|
// already logs, so an operator can tell one viewing of a title from the next without
|
|
// the two records having to agree on anything but this.
|
|
PlaySessionID string `json:"playSessionId,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"`
|
|
}
|
|
|
|
// JourneyFeatureStat is the server-derived usage summary for one feature. Users are
|
|
// distinct profiles, Uses are recorded feature events, and Journeys are distinct app
|
|
// visits containing the feature.
|
|
type JourneyFeatureStat struct {
|
|
Feature string `json:"feature"`
|
|
Users int64 `json:"users"`
|
|
Uses int64 `json:"uses"`
|
|
Journeys int64 `json:"journeys"`
|
|
ActiveUserRate float64 `json:"activeUserRate"`
|
|
LastUsedAt time.Time `json:"lastUsedAt"`
|
|
}
|
|
|
|
// JourneyBreakdownStat keeps the three telemetry dimensions separate. SubFeature is
|
|
// sourced from the structured entry/source/target context, never parsed from display text.
|
|
type JourneyBreakdownStat struct {
|
|
Feature string `json:"feature"`
|
|
SubFeature string `json:"subFeature"`
|
|
Action string `json:"action"`
|
|
Users int64 `json:"users"`
|
|
Uses int64 `json:"uses"`
|
|
Journeys int64 `json:"journeys"`
|
|
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"`
|
|
}
|
|
|
|
// ViewStats is app use measured at the launcher, rather than media playback. A visit is a
|
|
// foreground journey that reached home; a viewer is one signed-in profile in that window.
|
|
type ViewStats struct {
|
|
Visits int64 `json:"visits"`
|
|
Viewers int64 `json:"viewers"`
|
|
}
|
|
|
|
type ViewBucket struct {
|
|
Label string `json:"label"`
|
|
Visits int64 `json:"visits"`
|
|
Viewers int64 `json:"viewers"`
|
|
}
|
|
|
|
type ViewsReport struct {
|
|
Today ViewStats `json:"today"`
|
|
LastWeek ViewStats `json:"lastWeek"`
|
|
Daily []ViewBucket `json:"daily"`
|
|
Hourly []ViewBucket `json:"hourly"`
|
|
BusiestHour string `json:"busiestHour"`
|
|
}
|
|
|
|
// ViewsReport aggregates only app launcher openings. `journey_start` remains in the
|
|
// predicate so deployments gain a useful history before clients begin sending home_open.
|
|
func (s *Store) ViewsReport(ctx context.Context, now time.Time) (ViewsReport, error) {
|
|
var out ViewsReport
|
|
location, err := time.LoadLocation("Pacific/Auckland")
|
|
if err != nil {
|
|
location = time.UTC
|
|
}
|
|
localNow := now.In(location)
|
|
todayStart := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, location).UTC()
|
|
// The dashboard comparison ends at the same elapsed point in last week's day, not at
|
|
// midnight, so a morning view is compared with another morning rather than a full day.
|
|
lastWeekStart := todayStart.AddDate(0, 0, -7)
|
|
lastWeekEnd := lastWeekStart.Add(now.Sub(todayStart))
|
|
count := func(start, end time.Time) (ViewStats, error) {
|
|
var value ViewStats
|
|
err := s.pool.QueryRow(ctx, `SELECT count(DISTINCT journey_id), count(DISTINCT emby_user_id)
|
|
FROM journey_events WHERE occurred_at >= $1 AND occurred_at < $2
|
|
AND action IN ('home_open', 'journey_start')`, start, end).Scan(&value.Visits, &value.Viewers)
|
|
return value, err
|
|
}
|
|
if out.Today, err = count(todayStart, now); err != nil {
|
|
return out, fmt.Errorf("store: today views: %w", err)
|
|
}
|
|
if out.LastWeek, err = count(lastWeekStart, lastWeekEnd); err != nil {
|
|
return out, fmt.Errorf("store: last-week views: %w", err)
|
|
}
|
|
rows, err := s.pool.Query(ctx, `SELECT to_char(timezone('Pacific/Auckland', occurred_at), 'YYYY-MM-DD'),
|
|
count(DISTINCT journey_id), count(DISTINCT emby_user_id)
|
|
FROM journey_events WHERE occurred_at >= $1 AND action IN ('home_open', 'journey_start')
|
|
GROUP BY 1 ORDER BY 1`, todayStart.AddDate(0, 0, -29))
|
|
if err != nil {
|
|
return out, fmt.Errorf("store: daily views: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
var bucket ViewBucket
|
|
if err := rows.Scan(&bucket.Label, &bucket.Visits, &bucket.Viewers); err != nil {
|
|
rows.Close()
|
|
return out, err
|
|
}
|
|
out.Daily = append(out.Daily, bucket)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return out, err
|
|
}
|
|
rows.Close()
|
|
rows, err = s.pool.Query(ctx, `SELECT to_char(timezone('Pacific/Auckland', occurred_at), 'HH24:00'),
|
|
count(DISTINCT journey_id), count(DISTINCT emby_user_id)
|
|
FROM journey_events WHERE occurred_at >= $1 AND occurred_at < $2 AND action IN ('home_open', 'journey_start')
|
|
GROUP BY 1 ORDER BY 1`, todayStart, now)
|
|
if err != nil {
|
|
return out, fmt.Errorf("store: hourly views: %w", err)
|
|
}
|
|
var busiestVisits int64
|
|
for rows.Next() {
|
|
var bucket ViewBucket
|
|
if err := rows.Scan(&bucket.Label, &bucket.Visits, &bucket.Viewers); err != nil {
|
|
rows.Close()
|
|
return out, err
|
|
}
|
|
out.Hourly = append(out.Hourly, bucket)
|
|
if bucket.Visits > busiestVisits {
|
|
busiestVisits = bucket.Visits
|
|
out.BusiestHour = bucket.Label
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return out, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// 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_id, item_name, item_type, play_session_id, outcome)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
|
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.ItemID, event.ItemName, event.ItemType,
|
|
event.PlaySessionID, 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()
|
|
}
|
|
|
|
// JourneyFeatureUsage aggregates structured journey telemetry. Lifecycle markers are not
|
|
// feature usage: screen views and feature actions are retained so a feature can be viewed
|
|
// without being selected, requested or played.
|
|
func (s *Store) JourneyFeatureUsage(ctx context.Context, userID string, since time.Time) ([]JourneyFeatureStat, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
WITH active AS (
|
|
SELECT count(DISTINCT emby_user_id) AS users
|
|
FROM journey_events WHERE occurred_at >= $1
|
|
), events AS (
|
|
SELECT coalesce(nullif(feature, ''), nullif(category, '')) AS feature,
|
|
emby_user_id, journey_id, occurred_at
|
|
FROM journey_events
|
|
WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2)
|
|
AND action NOT IN ('journey_start', 'journey_end')
|
|
)
|
|
SELECT feature, count(DISTINCT emby_user_id), count(*), count(DISTINCT (emby_user_id, journey_id)),
|
|
coalesce(count(DISTINCT emby_user_id)::double precision /
|
|
nullif((SELECT users FROM active), 0), 0), max(occurred_at)
|
|
FROM events
|
|
WHERE feature IS NOT NULL AND feature <> ''
|
|
GROUP BY feature ORDER BY count(*) DESC, feature`, since, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: journey feature usage: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := []JourneyFeatureStat{}
|
|
for rows.Next() {
|
|
var value JourneyFeatureStat
|
|
if err := rows.Scan(&value.Feature, &value.Users, &value.Uses, &value.Journeys,
|
|
&value.ActiveUserRate, &value.LastUsedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, value)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// JourneyFeatureBreakdown is the drill-down behind the feature summary. Source is the
|
|
// strongest sub-feature signal (for example text or voice search); target and screen are
|
|
// fallbacks for older structured events.
|
|
func (s *Store) JourneyFeatureBreakdown(ctx context.Context, userID string, since time.Time) ([]JourneyBreakdownStat, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT coalesce(nullif(feature, ''), nullif(category, '')) AS feature,
|
|
coalesce(nullif(source, ''), nullif(target, ''), nullif(screen, ''), '') AS sub_feature,
|
|
action, count(DISTINCT emby_user_id), count(*), count(DISTINCT (emby_user_id, journey_id)), max(occurred_at)
|
|
FROM journey_events
|
|
WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2)
|
|
AND action NOT IN ('journey_start', 'journey_end')
|
|
AND coalesce(nullif(feature, ''), nullif(category, '')) IS NOT NULL
|
|
GROUP BY coalesce(nullif(feature, ''), nullif(category, '')),
|
|
coalesce(nullif(source, ''), nullif(target, ''), nullif(screen, ''), ''), action
|
|
ORDER BY count(*) DESC, feature, sub_feature, action`, since, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store: journey feature breakdown: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := []JourneyBreakdownStat{}
|
|
for rows.Next() {
|
|
var value JourneyBreakdownStat
|
|
if err := rows.Scan(&value.Feature, &value.SubFeature, &value.Action, &value.Users,
|
|
&value.Uses, &value.Journeys, &value.LastUsedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, value)
|
|
}
|
|
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_id, item_name, item_type,
|
|
play_session_id, 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.ItemID, &v.ItemName, &v.ItemType, &v.PlaySessionID, &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
|
|
}
|