181 lines
8.0 KiB
Go
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()
|
||
|
|
}
|