This commit is contained in:
ponzischeme89
2026-08-18 08:41:48 +12:00
parent 1da91e40a1
commit 36d171e51b
50 changed files with 4972 additions and 377 deletions
+14 -6
View File
@@ -72,9 +72,15 @@ type JourneyEvent struct {
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"`
Outcome string `json:"outcome,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 {
@@ -306,12 +312,13 @@ func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent)
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)
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.ItemName, event.ItemType, event.Outcome)
event.Target, event.ItemID, event.ItemName, event.ItemType,
event.PlaySessionID, event.Outcome)
}
results := s.pool.SendBatch(ctx, batch)
defer results.Close()
@@ -464,7 +471,8 @@ func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) (
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
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 {
@@ -474,7 +482,7 @@ func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time
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 {
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)
+18 -9
View File
@@ -23,13 +23,19 @@ type NotificationPreferences struct {
UpdateAlerts bool `json:"updateAlerts"`
LibraryAlerts bool `json:"libraryAlerts"`
SystemAlerts bool `json:"systemAlerts"`
LeadDays int `json:"leadDays"`
// WatchTimeDigest is the weekly viewing summary. It is its own switch rather than part
// of SystemAlerts because it is the only notification here that is about the viewer
// rather than about the library or the server, and somebody who wants to be told a show
// was cancelled may well not want to be told how long they spent watching it.
WatchTimeDigest bool `json:"watchTimeDigest"`
LeadDays int `json:"leadDays"`
}
func DefaultNotificationPreferences() NotificationPreferences {
return NotificationPreferences{
Enabled: true, ShowReturnAlerts: true, SonarrAlerts: true, RadarrAlerts: true,
UpdateAlerts: true, LibraryAlerts: true, SystemAlerts: true, LeadDays: 7,
UpdateAlerts: true, LibraryAlerts: true, SystemAlerts: true,
WatchTimeDigest: true, LeadDays: 7,
}
}
@@ -210,10 +216,11 @@ func (s *Store) NotificationPreferences(ctx context.Context, userID string) (Not
prefs := DefaultNotificationPreferences()
err := s.pool.QueryRow(ctx, `
SELECT enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
update_alerts, library_alerts, system_alerts, lead_days
update_alerts, library_alerts, system_alerts, watch_time_digest, lead_days
FROM user_notification_preferences WHERE emby_user_id = $1`, userID).
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts, &prefs.RadarrAlerts,
&prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts, &prefs.LeadDays)
&prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts,
&prefs.WatchTimeDigest, &prefs.LeadDays)
if err != nil && !isNoRows(err) {
return prefs, fmt.Errorf("store: notification preferences: %w", err)
}
@@ -232,8 +239,8 @@ func (s *Store) SetNotificationPreferences(
_, err := s.pool.Exec(ctx, `
INSERT INTO user_notification_preferences
(emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
update_alerts, library_alerts, system_alerts, lead_days)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
update_alerts, library_alerts, system_alerts, watch_time_digest, lead_days)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (emby_user_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
show_return_alerts = EXCLUDED.show_return_alerts,
@@ -242,17 +249,19 @@ func (s *Store) SetNotificationPreferences(
update_alerts = EXCLUDED.update_alerts,
library_alerts = EXCLUDED.library_alerts,
system_alerts = EXCLUDED.system_alerts,
watch_time_digest = EXCLUDED.watch_time_digest,
lead_days = EXCLUDED.lead_days,
updated_at = now()`,
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.SonarrAlerts, prefs.RadarrAlerts,
prefs.UpdateAlerts, prefs.LibraryAlerts, prefs.SystemAlerts, prefs.LeadDays)
prefs.UpdateAlerts, prefs.LibraryAlerts, prefs.SystemAlerts, prefs.WatchTimeDigest,
prefs.LeadDays)
return err
}
func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]NotificationPreferences, error) {
rows, err := s.pool.Query(ctx, `
SELECT emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
update_alerts, library_alerts, system_alerts, lead_days
update_alerts, library_alerts, system_alerts, watch_time_digest, lead_days
FROM user_notification_preferences`)
if err != nil {
return nil, fmt.Errorf("store: list notification preferences: %w", err)
@@ -264,7 +273,7 @@ func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]Noti
var userID string
if err := rows.Scan(&userID, &prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts,
&prefs.RadarrAlerts, &prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts,
&prefs.LeadDays); err != nil {
&prefs.WatchTimeDigest, &prefs.LeadDays); err != nil {
return nil, fmt.Errorf("store: scan notification preferences: %w", err)
}
result[userID] = prefs
+5
View File
@@ -199,6 +199,9 @@ CREATE TABLE IF NOT EXISTS journey_events (
);
ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS item_name TEXT NOT NULL DEFAULT '';
-- Emby's id for the stream a playback step describes. Empty on every other kind of step,
-- and on every row written before playback steps carried one.
ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS play_session_id TEXT NOT NULL DEFAULT '';
CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx
ON journey_events (emby_user_id, journey_id, sequence);
@@ -243,6 +246,7 @@ CREATE TABLE IF NOT EXISTS user_notification_preferences (
update_alerts BOOLEAN NOT NULL DEFAULT true,
library_alerts BOOLEAN NOT NULL DEFAULT true,
system_alerts BOOLEAN NOT NULL DEFAULT true,
watch_time_digest BOOLEAN NOT NULL DEFAULT true,
lead_days INT NOT NULL DEFAULT 7 CHECK (lead_days BETWEEN 1 AND 30),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -255,6 +259,7 @@ ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS radarr_alerts
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS update_alerts BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS library_alerts BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS system_alerts BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS watch_time_digest BOOLEAN NOT NULL DEFAULT true;
-- Notifications are materialised so read/dismissed state follows the user to every TV.
-- source_key is deterministic, preventing the same return date from being announced
+180
View File
@@ -0,0 +1,180 @@
package store
import (
"context"
"fmt"
"time"
)
// Watch time is read out of tracearr_sessions rather than stored again.
//
// Tracearr is already the household's record of who watched what and for how long, and the
// import that feeds recommendations has written every one of those rows into Postgres — so
// a second table counting minutes would be a copy of a copy, wrong the moment Tracearr
// corrects a session and needing its own reconciliation to stay honest. Everything below is
// therefore a query, and the only cost of adding this feature is the reading.
//
// watchedMsExpr is the one definition of "how long was this actually watched", and it exists
// in exactly this one place so the console's figure and the digest's figure can never
// disagree. Tracearr reports two overlapping numbers — durationMs, which is aggregate watch
// time, and progressMs, the furthest point reached — and neither is reliably the larger, so
// the greater of the two is taken. It is then capped at the title's own length, because a
// session somebody left running against a title they rewound through can otherwise report
// more watching than the programme contains. A zero total means Tracearr did not say how
// long the title was, and NULLIF hands that case to LEAST as NULL, which Postgres ignores —
// so an unknown length caps nothing rather than capping everything to zero.
const watchedMsExpr = `GREATEST(LEAST(GREATEST(duration_ms, progress_ms), NULLIF(total_duration_ms, 0)), 0)`
// watchedTitleExpr names what was watched the way a person would. An episode is reported as
// its series, for the same reason Session.TitleKey does it: "three hours of Severance" is
// the useful sentence, and "forty minutes of Chikhai Bardo" is a fact about one episode
// nobody asked about.
const watchedTitleExpr = `CASE WHEN lower(media_type) = 'episode' AND show_title <> ''
THEN show_title ELSE media_title END`
// WatchTimeTotals is one Tracearr identity's viewing, in the three windows the console
// shows at once. Username is carried beside the id because the id is what Tracearr calls
// somebody and the name is the only thing that can be matched against an Emby account.
type WatchTimeTotals struct {
TracearrUserID string `json:"tracearrUserId,omitempty"`
Username string `json:"username"`
WeekMs int64 `json:"weekMs"`
MonthMs int64 `json:"monthMs"`
TotalMs int64 `json:"totalMs"`
WeekSessions int `json:"weekSessions"`
MonthSessions int `json:"monthSessions"`
LastWatchedAt *time.Time `json:"lastWatchedAt,omitempty"`
}
// WatchTimeRange is one identity's viewing inside a closed window, with the title most of it
// went on. Kept apart from WatchTimeTotals because a window that has *ended* is a different
// question from a running total: it is what the month-end summary reports, and it is the only
// one of the two for which naming a top title is worth an extra pass over the rows.
type WatchTimeRange struct {
TracearrUserID string
Username string
Ms int64
Sessions int
TopTitle string
TopTitleMs int64
}
// TracearrWatchTime totals the two running windows and the lifetime figure in one pass.
//
// One query rather than three because this is read while an operator waits for the accounts
// page, and because the three numbers must describe the same instant — three queries against
// a table an import is writing into could report a week larger than the month containing it.
//
// The boundaries are passed in rather than computed here: a week begins on the household's
// local Monday, and the store has no idea which timezone the household keeps.
func (s *Store) TracearrWatchTime(
ctx context.Context,
weekStart, monthStart time.Time,
) ([]WatchTimeTotals, error) {
rows, err := s.pool.Query(ctx, `
SELECT
max(tracearr_user_id) AS tracearr_user_id,
max(username) AS username,
coalesce(sum(`+watchedMsExpr+`) FILTER (WHERE started_at >= $1), 0)::bigint AS week_ms,
coalesce(sum(`+watchedMsExpr+`) FILTER (WHERE started_at >= $2), 0)::bigint AS month_ms,
coalesce(sum(`+watchedMsExpr+`), 0)::bigint AS total_ms,
count(*) FILTER (WHERE started_at >= $1) AS week_sessions,
count(*) FILTER (WHERE started_at >= $2) AS month_sessions,
max(started_at) AS last_watched_at
FROM tracearr_sessions
WHERE username <> ''
GROUP BY lower(username)
ORDER BY total_ms DESC`,
weekStart, monthStart)
if err != nil {
return nil, fmt.Errorf("store: tracearr watch time: %w", err)
}
defer rows.Close()
out := []WatchTimeTotals{}
for rows.Next() {
var totals WatchTimeTotals
if err := rows.Scan(
&totals.TracearrUserID, &totals.Username, &totals.WeekMs, &totals.MonthMs,
&totals.TotalMs, &totals.WeekSessions, &totals.MonthSessions, &totals.LastWatchedAt,
); err != nil {
return nil, fmt.Errorf("store: scan tracearr watch time: %w", err)
}
out = append(out, totals)
}
return out, rows.Err()
}
// TracearrWatchTimeRange totals a closed window, half-open on the right so two adjacent
// months can never both claim a session started on the stroke of midnight.
func (s *Store) TracearrWatchTimeRange(
ctx context.Context,
from, to time.Time,
) ([]WatchTimeRange, error) {
rows, err := s.pool.Query(ctx, `
WITH watched AS (
SELECT lower(username) AS user_key, tracearr_user_id, username,
`+watchedTitleExpr+` AS title,
`+watchedMsExpr+` AS ms
FROM tracearr_sessions
WHERE username <> '' AND started_at >= $1 AND started_at < $2
),
totals AS (
SELECT user_key, max(tracearr_user_id) AS tracearr_user_id, max(username) AS username,
coalesce(sum(ms), 0)::bigint AS ms, count(*) AS sessions
FROM watched GROUP BY user_key
),
titles AS (
SELECT user_key, title, coalesce(sum(ms), 0)::bigint AS ms
FROM watched WHERE title <> '' GROUP BY user_key, title
),
tops AS (
SELECT DISTINCT ON (user_key) user_key, title, ms
FROM titles ORDER BY user_key, ms DESC, title
)
SELECT totals.tracearr_user_id, totals.username, totals.ms, totals.sessions,
coalesce(tops.title, ''), coalesce(tops.ms, 0)
FROM totals LEFT JOIN tops ON tops.user_key = totals.user_key
ORDER BY totals.ms DESC`,
from, to)
if err != nil {
return nil, fmt.Errorf("store: tracearr watch time range: %w", err)
}
defer rows.Close()
out := []WatchTimeRange{}
for rows.Next() {
var window WatchTimeRange
if err := rows.Scan(
&window.TracearrUserID, &window.Username, &window.Ms,
&window.Sessions, &window.TopTitle, &window.TopTitleMs,
); err != nil {
return nil, fmt.Errorf("store: scan tracearr watch time range: %w", err)
}
out = append(out, window)
}
return out, rows.Err()
}
// TracearrIdentities is every Emby account the recommendation builder has already matched to
// a Tracearr one. Watch time is attributed by username, which is the identity the two systems
// genuinely share — but a household that has renamed somebody in one and not the other would
// silently lose their figures, and this map is what lets the id carry them instead.
func (s *Store) TracearrIdentities(ctx context.Context) (map[string]RecommendationIdentity, error) {
rows, err := s.pool.Query(ctx, `
SELECT emby_user_id, tracearr_user_id, tracearr_username
FROM recommendation_user_profiles
WHERE tracearr_user_id <> '' OR tracearr_username <> ''`)
if err != nil {
return nil, fmt.Errorf("store: tracearr identities: %w", err)
}
defer rows.Close()
out := map[string]RecommendationIdentity{}
for rows.Next() {
var embyUserID string
var identity RecommendationIdentity
if err := rows.Scan(&embyUserID, &identity.TracearrUserID, &identity.Username); err != nil {
return nil, fmt.Errorf("store: scan tracearr identity: %w", err)
}
out[embyUserID] = identity
}
return out, rows.Err()
}