0.2.75
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user