Files
memby/server/internal/store/watch_time.go
T
2026-08-18 08:41:48 +12:00

181 lines
8.0 KiB
Go

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()
}