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{} }