367 lines
15 KiB
Go
367 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/ponzischeme89/memby/server/internal/store"
|
|
)
|
|
|
|
// Watch time is Tracearr's answer, attributed to Emby accounts and read two ways: as figures
|
|
// beside a person in the console, and as the weekly summary the television shows them.
|
|
//
|
|
// Everything about *when* a week begins and *how* a span is worded is in this file as pure
|
|
// functions, because both readers have to agree — a console saying eleven hours and a digest
|
|
// saying "10h 58m" for the same window is the sort of disagreement that makes an operator
|
|
// stop trusting both numbers.
|
|
|
|
const (
|
|
// digestWeekday and digestHour are when the weekly summary goes out, in the household's
|
|
// own time. Sunday evening rather than Monday morning because the figure sent is
|
|
// week-to-date: on a Monday it would be a summary of almost nothing, and by Sunday
|
|
// evening it is the week the viewer actually had.
|
|
digestWeekday = time.Sunday
|
|
digestHour = 20
|
|
|
|
// watchTimeDigestFloor is the least viewing worth telling somebody about. A digest
|
|
// reporting four minutes is a notification about a title somebody started and abandoned,
|
|
// and a feed that reports those is one nobody opens.
|
|
watchTimeDigestFloor = 20 * time.Minute
|
|
)
|
|
|
|
// weekStartIn is the local Monday at midnight on or before now.
|
|
//
|
|
// Monday rather than Sunday because that is the week New Zealand keeps, and the boundary is
|
|
// computed by *date* rather than by subtracting hours: a week containing a daylight-saving
|
|
// change is 23 or 25 hours short or long, and now.Add(-7*24*time.Hour) would put the boundary
|
|
// an hour inside the previous Sunday twice a year.
|
|
func weekStartIn(now time.Time, location *time.Location) time.Time {
|
|
local := now.In(location)
|
|
offset := (int(local.Weekday()) + 6) % 7 // Monday becomes 0
|
|
day := local.AddDate(0, 0, -offset)
|
|
return time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, location)
|
|
}
|
|
|
|
// monthStartIn is local midnight on the first of the month containing now.
|
|
func monthStartIn(now time.Time, location *time.Location) time.Time {
|
|
local := now.In(location)
|
|
return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, location)
|
|
}
|
|
|
|
// previousMonth is the closed window of the calendar month before the one containing now, and
|
|
// the YYYY-MM key that names it. Half-open on the right, so a session that started on the
|
|
// stroke of midnight belongs to exactly one of the two months.
|
|
func previousMonth(now time.Time, location *time.Location) (from, to time.Time, key string) {
|
|
to = monthStartIn(now, location)
|
|
from = to.AddDate(0, -1, 0)
|
|
return from, to, from.Format("2006-01")
|
|
}
|
|
|
|
// priorWeekToDate is last week measured to the same point this week has reached: the local
|
|
// Monday a week ago, up to the same weekday and wall-clock time as now. AddDate keeps the
|
|
// wall clock across a daylight-saving change, so "the same time last week" stays the same
|
|
// time rather than drifting an hour — the reason weekStartIn works in dates too.
|
|
func priorWeekToDate(now time.Time, location *time.Location) (from, to time.Time) {
|
|
from = weekStartIn(now, location).AddDate(0, 0, -7)
|
|
to = now.In(location).AddDate(0, 0, -7)
|
|
return from, to
|
|
}
|
|
|
|
// watchWeekTrendFloor is how far this week's figure can sit from last week's before the
|
|
// difference is worth showing. The band is the larger of this and a tenth of last week —
|
|
// an absolute floor so a light household is not told "down" over a few minutes, and a
|
|
// proportion so a heavy one is not told "steady" over an hour.
|
|
const watchWeekTrendFloor = 5 * time.Minute
|
|
|
|
// watchWeekTrend is this week-to-date against the same point last week: a direction the
|
|
// console prints beside the figure, plus the numbers behind it. Direction is "up", "down",
|
|
// "steady" or "none" — the last when there is nothing to compare, the runtimestats
|
|
// DirectionUnknown stance.
|
|
type watchWeekTrend struct {
|
|
Direction string `json:"direction"`
|
|
PriorMs int64 `json:"priorMs"`
|
|
DeltaMs int64 `json:"deltaMs"`
|
|
}
|
|
|
|
// weekOverWeek classifies this week's watch time against last week's to-date figure. Pure,
|
|
// so the console and its tests agree on where "steady" ends.
|
|
func weekOverWeek(currentMs, priorMs int64) watchWeekTrend {
|
|
if currentMs <= 0 && priorMs <= 0 {
|
|
return watchWeekTrend{Direction: "none"}
|
|
}
|
|
delta := currentMs - priorMs
|
|
band := priorMs / 10
|
|
if floor := watchWeekTrendFloor.Milliseconds(); band < floor {
|
|
band = floor
|
|
}
|
|
direction := "steady"
|
|
switch {
|
|
case delta > band:
|
|
direction = "up"
|
|
case delta < -band:
|
|
direction = "down"
|
|
}
|
|
return watchWeekTrend{Direction: direction, PriorMs: priorMs, DeltaMs: delta}
|
|
}
|
|
|
|
// weekKey names a week the way the source key needs it — a stable string that changes exactly
|
|
// once per week. ISO year-and-week, so the last days of December cannot collide with the
|
|
// first days of January.
|
|
func weekKey(now time.Time, location *time.Location) string {
|
|
year, week := weekStartIn(now, location).ISOWeek()
|
|
return fmt.Sprintf("%04d-W%02d", year, week)
|
|
}
|
|
|
|
// weeklyDigestDue is the whole schedule rule, kept pure so the send window is testable
|
|
// without waiting a week for one.
|
|
//
|
|
// The task runs hourly and this says yes for every run from the appointed hour to the end of
|
|
// that day, rather than only on the hour itself: a container restarted at eight on a Sunday
|
|
// evening, or a run that failed, must still deliver the summary. Sending it twice is prevented
|
|
// by the notification's source key rather than by narrowing this window, which is the same
|
|
// trade the *arr lifecycle scanner makes — an idempotent write is a better guard than a timer.
|
|
func weeklyDigestDue(now time.Time, location *time.Location) bool {
|
|
local := now.In(location)
|
|
return local.Weekday() == digestWeekday && local.Hour() >= digestHour
|
|
}
|
|
|
|
// monthlyDigestDue says whether the *previous* month may be summarised yet. Any run at or
|
|
// after the appointed hour qualifies, so a gateway that was switched off over the turn of the
|
|
// month still delivers the summary when it comes back rather than skipping it for ever.
|
|
func monthlyDigestDue(now time.Time, location *time.Location) bool {
|
|
return now.In(location).Hour() >= digestHour
|
|
}
|
|
|
|
// formatWatchDuration is how a span of viewing is worded everywhere Memby says one out loud.
|
|
//
|
|
// Rounded to the minute, because a viewing figure carrying seconds invites somebody to
|
|
// reconcile it against something, and nothing it is derived from is accurate to the second.
|
|
func formatWatchDuration(d time.Duration) string {
|
|
minutes := int(d.Round(time.Minute) / time.Minute)
|
|
if minutes <= 0 {
|
|
return "no time"
|
|
}
|
|
hours := minutes / 60
|
|
minutes %= 60
|
|
switch {
|
|
case hours == 0:
|
|
return fmt.Sprintf("%d min", minutes)
|
|
case minutes == 0 && hours == 1:
|
|
return "1 hour"
|
|
case minutes == 0:
|
|
return fmt.Sprintf("%d hours", hours)
|
|
default:
|
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
|
}
|
|
}
|
|
|
|
// weeklyDigestMessage is the sentence a viewer reads. Both figures are in it because the
|
|
// question "have I watched a lot this week" is only answerable beside the month it sits in —
|
|
// and the month is dropped when the two are the same span, which is what the first week of a
|
|
// month looks like, rather than printing the same number twice.
|
|
func weeklyDigestMessage(week, month time.Duration, topTitle string) string {
|
|
var b strings.Builder
|
|
b.WriteString("You watched " + formatWatchDuration(week) + " this week")
|
|
if month > week {
|
|
b.WriteString(", and " + formatWatchDuration(month) + " so far this month")
|
|
}
|
|
b.WriteString(".")
|
|
if title := strings.TrimSpace(topTitle); title != "" {
|
|
b.WriteString(" Mostly " + title + ".")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// monthlyDigestMessage summarises a month that has ended. It names the month, because unlike
|
|
// the weekly note this can arrive days after the window it describes closed.
|
|
func monthlyDigestMessage(month time.Duration, monthName, topTitle string) string {
|
|
var b strings.Builder
|
|
b.WriteString("You watched " + formatWatchDuration(month) + " in " + monthName + ".")
|
|
if title := strings.TrimSpace(topTitle); title != "" {
|
|
b.WriteString(" Most of it on " + title + ".")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// watchTimeSummary is one account's figures as the console reads them. Matched is carried
|
|
// because "nobody by that name in Tracearr" and "somebody who has watched nothing" are
|
|
// different facts, and a console that showed both as a row of zeroes would leave an operator
|
|
// investigating a viewer rather than an integration.
|
|
type watchTimeSummary struct {
|
|
Matched bool `json:"matched"`
|
|
Username string `json:"tracearrUsername,omitempty"`
|
|
WeekMs int64 `json:"weekMs"`
|
|
// WeekTrend compares WeekMs with the same point last week, so the console can show
|
|
// whether a person's viewing is up or down without the operator holding last week's
|
|
// figure in their head.
|
|
WeekTrend watchWeekTrend `json:"weekTrend"`
|
|
MonthMs int64 `json:"monthMs"`
|
|
TotalMs int64 `json:"totalMs"`
|
|
WeekSessions int `json:"weekSessions"`
|
|
MonthSessions int `json:"monthSessions"`
|
|
LastWatchedAt *time.Time `json:"lastWatchedAt,omitempty"`
|
|
}
|
|
|
|
// tracearrEnabled is whether there is anything to read at all. Every watch-time reader checks
|
|
// it first, so a household running no Tracearr pays no query for a feature it cannot have.
|
|
func (s *Server) tracearrEnabled() bool {
|
|
return strings.TrimSpace(s.cfg.TracearrURL) != "" && strings.TrimSpace(s.cfg.TracearrAPIKey) != ""
|
|
}
|
|
|
|
// attributeWatchTime maps Tracearr identities onto Emby user ids.
|
|
//
|
|
// The username is the join, because it is the identity the two systems genuinely share and
|
|
// the only one present for a viewer the recommendation builder has never profiled. The
|
|
// recorded identity map wins where it exists: it is what the builder actually matched, so a
|
|
// household that has since renamed somebody in one system keeps their figures instead of
|
|
// silently reporting zero.
|
|
//
|
|
// It is a pure function over both readings so the matching rule can be tested without a
|
|
// database, and so the console and the digest cannot attribute the same rows differently.
|
|
func attributeWatchTime(
|
|
accounts []watchTimeAccount,
|
|
totals []store.WatchTimeTotals,
|
|
identities map[string]store.RecommendationIdentity,
|
|
) map[string]store.WatchTimeTotals {
|
|
byName := make(map[string]store.WatchTimeTotals, len(totals))
|
|
byID := make(map[string]store.WatchTimeTotals, len(totals))
|
|
for _, entry := range totals {
|
|
if key := strings.ToLower(strings.TrimSpace(entry.Username)); key != "" {
|
|
byName[key] = entry
|
|
}
|
|
if entry.TracearrUserID != "" {
|
|
byID[entry.TracearrUserID] = entry
|
|
}
|
|
}
|
|
out := make(map[string]store.WatchTimeTotals, len(accounts))
|
|
for _, account := range accounts {
|
|
identity := identities[account.ID]
|
|
if identity.TracearrUserID != "" {
|
|
if entry, ok := byID[identity.TracearrUserID]; ok {
|
|
out[account.ID] = entry
|
|
continue
|
|
}
|
|
}
|
|
for _, candidate := range []string{identity.Username, account.Username} {
|
|
key := strings.ToLower(strings.TrimSpace(candidate))
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if entry, ok := byName[key]; ok {
|
|
out[account.ID] = entry
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// watchTimeAccount is the little of an account the attribution rule needs. Declared here
|
|
// rather than taking store.MembyAccount so the rule can be tested with two fields and so the
|
|
// digest, which works from sessions rather than from accounts, can use it too.
|
|
type watchTimeAccount struct {
|
|
ID string
|
|
Username string
|
|
}
|
|
|
|
// watchTimeForAccounts is the console's reading: one grouped query, attributed, and never
|
|
// fatal. A Tracearr table that will not read must not cost the operator the account list.
|
|
func (s *Server) watchTimeForAccounts(
|
|
ctx context.Context,
|
|
accounts []watchTimeAccount,
|
|
) (totals map[string]store.WatchTimeTotals, priorWeekMs map[string]int64) {
|
|
if !s.tracearrEnabled() || len(accounts) == 0 {
|
|
return nil, nil
|
|
}
|
|
location := s.householdLocation()
|
|
now := time.Now()
|
|
rows, err := s.store.TracearrWatchTime(ctx, weekStartIn(now, location), monthStartIn(now, location))
|
|
if err != nil {
|
|
s.loggerFor(ctx).Warn("watch time read failed", "error", err)
|
|
return nil, nil
|
|
}
|
|
identities, err := s.store.TracearrIdentities(ctx)
|
|
if err != nil {
|
|
// The username join still works without it; only a renamed viewer loses their
|
|
// figures, which is better than the whole page losing them.
|
|
s.loggerFor(ctx).Warn("Tracearr identity map unavailable", "error", err)
|
|
identities = map[string]store.RecommendationIdentity{}
|
|
}
|
|
attributed := attributeWatchTime(accounts, rows, identities)
|
|
|
|
// Last week to the same point, attributed the same two ways, so the console can say
|
|
// whether viewing is up or down. A failure here costs the arrow, never the figures.
|
|
priorWeek := map[string]int64{}
|
|
priorFrom, priorTo := priorWeekToDate(now, location)
|
|
windows, err := s.store.TracearrWatchTimeRange(ctx, priorFrom, priorTo)
|
|
if err != nil {
|
|
s.loggerFor(ctx).Warn("prior-week watch time read failed", "error", err)
|
|
} else {
|
|
byID, byName := indexWatchTimeRanges(windows)
|
|
for _, account := range accounts {
|
|
window := lookupWatchTimeRange(byID, byName, identities[account.ID], account.Username)
|
|
priorWeek[account.ID] = window.Ms
|
|
}
|
|
}
|
|
return attributed, priorWeek
|
|
}
|
|
|
|
// summariseWatchTime turns the store's row into what the console reads, including the case
|
|
// where there is no row. priorWeekMs is last week's viewing to the same point, for the
|
|
// week-over-week arrow — zero when there is nothing recorded, which weekOverWeek reads as
|
|
// "no change to show" rather than as a fall to nothing.
|
|
func summariseWatchTime(totals store.WatchTimeTotals, matched bool, priorWeekMs int64) watchTimeSummary {
|
|
if !matched {
|
|
return watchTimeSummary{}
|
|
}
|
|
return watchTimeSummary{
|
|
Matched: true, Username: totals.Username,
|
|
WeekMs: totals.WeekMs, WeekTrend: weekOverWeek(totals.WeekMs, priorWeekMs),
|
|
MonthMs: totals.MonthMs, TotalMs: totals.TotalMs,
|
|
WeekSessions: totals.WeekSessions, MonthSessions: totals.MonthSessions,
|
|
LastWatchedAt: totals.LastWatchedAt,
|
|
}
|
|
}
|
|
|
|
// indexWatchTimeRanges keys a window's rows for lookup by either identity. Both maps are
|
|
// built because the digest resolves the same two ways attributeWatchTime does — by the id the
|
|
// recommendation builder recorded where there is one, and by name otherwise.
|
|
func indexWatchTimeRanges(windows []store.WatchTimeRange) (byID, byName map[string]store.WatchTimeRange) {
|
|
byID = make(map[string]store.WatchTimeRange, len(windows))
|
|
byName = make(map[string]store.WatchTimeRange, len(windows))
|
|
for _, window := range windows {
|
|
if window.TracearrUserID != "" {
|
|
byID[window.TracearrUserID] = window
|
|
}
|
|
if key := strings.ToLower(strings.TrimSpace(window.Username)); key != "" {
|
|
byName[key] = window
|
|
}
|
|
}
|
|
return byID, byName
|
|
}
|
|
|
|
// lookupWatchTimeRange finds one person's row in an indexed window.
|
|
func lookupWatchTimeRange(
|
|
byID, byName map[string]store.WatchTimeRange,
|
|
identity store.RecommendationIdentity,
|
|
username string,
|
|
) store.WatchTimeRange {
|
|
if identity.TracearrUserID != "" {
|
|
if window, ok := byID[identity.TracearrUserID]; ok {
|
|
return window
|
|
}
|
|
}
|
|
for _, candidate := range []string{identity.Username, username} {
|
|
key := strings.ToLower(strings.TrimSpace(candidate))
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if window, ok := byName[key]; ok {
|
|
return window
|
|
}
|
|
}
|
|
return store.WatchTimeRange{}
|
|
}
|