package store import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" ) // 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() } // TracearrIdentity is one Emby account's recorded Tracearr identity, or the zero value for // somebody the recommendation builder has never profiled. // // The map above is what the console needs — it draws the whole household in one pass — and // this is what a single request needs. Absence is not an error: a viewer with no profile // row is matched by username alone, which is the join the two systems genuinely share. func (s *Store) TracearrIdentity(ctx context.Context, embyUserID string) (RecommendationIdentity, error) { var identity RecommendationIdentity err := s.pool.QueryRow(ctx, ` SELECT tracearr_user_id, tracearr_username FROM recommendation_user_profiles WHERE emby_user_id = $1`, embyUserID).Scan(&identity.TracearrUserID, &identity.Username) if errors.Is(err, pgx.ErrNoRows) { return RecommendationIdentity{}, nil } if err != nil { return RecommendationIdentity{}, fmt.Errorf("store: tracearr identity: %w", err) } return identity, nil }