This commit is contained in:
ponzischeme89
2026-08-18 08:41:48 +12:00
parent 1da91e40a1
commit 36d171e51b
50 changed files with 4972 additions and 377 deletions
+15 -1
View File
@@ -51,6 +51,10 @@ type adminMembyAccount struct {
// themeAllowed take. The console renders that as every box ticked, which is what an
// operator who has never touched the page should see.
Themes []string `json:"themes"`
// WatchTime is what Tracearr says this person has watched. It is zero-valued and
// unmatched for a household running no Tracearr, which the console reads as "not
// available" rather than as "watched nothing".
WatchTime watchTimeSummary `json:"watchTime"`
}
type adminAccountSettings struct {
@@ -108,6 +112,14 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
notifications = map[string]store.NotificationPreferences{}
}
// One grouped query for the whole household rather than one per person: this page grows
// with the family, and a per-account read is how a directory becomes slow.
identifiers := make([]watchTimeAccount, 0, len(accounts))
for _, account := range accounts {
identifiers = append(identifiers, watchTimeAccount{ID: account.ID, Username: account.Username})
}
watchTime := s.watchTimeForAccounts(r.Context(), identifiers)
result := make([]adminMembyAccount, 0, len(accounts))
for _, account := range accounts {
pref := preferences[account.ID]
@@ -137,8 +149,10 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
if !savedNotifications {
notificationPrefs = store.DefaultNotificationPreferences()
}
watched, matchedWatchTime := watchTime[account.ID]
result = append(result, adminMembyAccount{
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
WatchTime: summariseWatchTime(watched, matchedWatchTime),
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
Themes: nonNilStrings(themes[account.ID]),
+12 -5
View File
@@ -43,10 +43,15 @@ type journeyEventPayload struct {
Feature string `json:"feature"`
Source string `json:"source"`
Target string `json:"target"`
ItemID string `json:"itemId"`
ItemName string `json:"itemName"`
ItemType string `json:"itemType"`
Outcome string `json:"outcome"`
OccurredAt string `json:"occurredAt"`
// The Emby play session a playback step belongs to. Validated like every other
// controlled field: it is Emby's string rather than ours, and an event carrying one this
// cannot read is dropped whole, so the television sanitises it before sending.
PlaySessionID string `json:"playSessionId"`
Outcome string `json:"outcome"`
OccurredAt string `json:"occurredAt"`
}
type journeyAnalyticsRequest struct {
@@ -100,7 +105,8 @@ func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (
!journeyOutcomes[payload.Outcome] {
return store.JourneyEvent{}, false
}
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target, payload.ItemType}
fields := []string{payload.Screen, payload.Feature, payload.Source, payload.Target,
payload.ItemID, payload.ItemType, payload.PlaySessionID}
for _, field := range fields {
if !safeAnalyticsValue(field, 100) {
return store.JourneyEvent{}, false
@@ -114,8 +120,9 @@ func toJourneyEvent(payload journeyEventPayload, userID string, now time.Time) (
return store.JourneyEvent{OccurredAt: occurredAt, UserID: userID, JourneyID: payload.JourneyID,
Sequence: payload.Sequence, Category: payload.Category, Action: payload.Action,
Screen: payload.Screen, Feature: payload.Feature, Source: payload.Source,
Target: payload.Target, ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType,
Outcome: payload.Outcome}, true
Target: payload.Target, ItemID: payload.ItemID,
ItemName: strings.TrimSpace(payload.ItemName), ItemType: payload.ItemType,
PlaySessionID: payload.PlaySessionID, Outcome: payload.Outcome}, true
}
func safeAnalyticsValue(value string, max int) bool {
+53
View File
@@ -37,6 +37,59 @@ func TestJourneyEventRejectsFreeTextAndUnknownVocabulary(t *testing.T) {
}
}
// The entry points the television states for a playback, and the two identifiers a playback
// step carries. Every one of these words has to survive the vocabulary check, or the step is
// dropped on arrival with nothing said and the journey reads as a viewer who reached the
// player from nowhere — which is precisely what Continue Watching and Magic used to look
// like, for want of the steps being recorded at all.
func TestJourneyEventAcceptsPlaybackEntryPoints(t *testing.T) {
now := time.Now().UTC()
for _, source := range []string{
"continue_watching", "magic_movie", "next_episode", "home_hero", "search",
"genre_browser", "calendar", "favourites", "recommendation", "detail_page",
"screensaver", "unknown",
} {
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Sequence: 1,
Category: "playback", Action: "request", Screen: "home", Feature: "playback",
Source: source, Target: "player", ItemID: "8821", ItemName: "Boy", ItemType: "Movie"}
event, ok := toJourneyEvent(payload, "u1", now)
if !ok {
t.Fatalf("entry point %q was rejected", source)
}
if event.Source != source || event.ItemID != "8821" {
t.Fatalf("entry point %q: unexpected event %+v", source, event)
}
}
}
func TestJourneyEventRetainsThePlaySession(t *testing.T) {
now := time.Now().UTC()
for _, outcome := range []string{"success", "failure", "completed", "abandoned"} {
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback",
Action: "start", Screen: "player", Feature: "playback", Source: "magic_movie",
Target: "player", ItemID: "42", ItemName: "Whale Rider", ItemType: "Movie",
PlaySessionID: "b3d1f0a4-9c22-4f61-8a10-77d0e2c9aa51", Outcome: outcome}
event, ok := toJourneyEvent(payload, "u1", now)
if !ok {
t.Fatalf("playback outcome %q was rejected", outcome)
}
if event.PlaySessionID != payload.PlaySessionID || event.Outcome != outcome {
t.Fatalf("outcome %q: unexpected event %+v", outcome, event)
}
}
}
// A play session id is Emby's string rather than the gateway's vocabulary, so it is checked
// like everything else: an unreadable one must not be written into the column.
func TestJourneyEventRejectsAnUnreadablePlaySession(t *testing.T) {
now := time.Now().UTC()
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "playback",
Action: "start", PlaySessionID: "session for Boy (2010)"}
if _, ok := toJourneyEvent(payload, "u1", now); ok {
t.Fatal("a play session id containing free text was accepted")
}
}
func TestJourneyEventRetainsTheItemName(t *testing.T) {
now := time.Now().UTC()
payload := journeyEventPayload{UserID: "u1", JourneyID: "j1", Category: "content",
+13
View File
@@ -26,6 +26,7 @@ const (
featureSeasonalDecorations = "seasonal_decorations"
featureGenreBrowser = "genre_browser"
featureTVCalendar = "tv_calendar"
featureWatchTimeDigest = "watch_time_digest"
)
type featureDefinition struct {
@@ -133,6 +134,18 @@ var featureCatalogue = []featureDefinition{
DefaultEnabled: true, MinimumProtocol: 1, Capability: "tv_calendar_v1",
Recovery: "Takes effect on the next status poll; the rail entry simply disappears.",
},
{
// No capability, because nothing on the television has to understand this: the
// summary is an ordinary entry in My Alerts, which every build that has that page
// already renders. This switch is the household's — a viewer's own is the
// watch-time toggle on their account.
Key: featureWatchTimeDigest, Name: "Weekly watch-time summary", Area: "Notifications",
Description: "Tell each viewer how long they watched this week and this month, on " +
"Sunday evening, with a summary of the month just gone once it ends. Read from " +
"Tracearr; a household running none never sends one.",
DefaultEnabled: true, MinimumProtocol: 1,
Recovery: "Server-enforced; takes effect before the next summary is due.",
},
{
Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup",
Description: "Ask a signed-in TV that cannot install its own updates to grant the " +
@@ -84,17 +84,25 @@ func filterStoredNotifications(notifications []store.UserNotification, prefs sto
if !prefs.Enabled {
return []store.UserNotification{}
}
if prefs.SonarrAlerts {
if prefs.SonarrAlerts && prefs.WatchTimeDigest {
return notifications
}
filtered := make([]store.UserNotification, 0, len(notifications))
for _, notification := range notifications {
switch notification.Kind {
case "show-return", "show-added", "show-cancelled", "auto-follow":
continue
default:
filtered = append(filtered, notification)
if !prefs.SonarrAlerts {
continue
}
// A summary already sitting in somebody's list is withdrawn the moment they turn
// these off, rather than waiting to be dismissed one at a time: switching a weekly
// notice off is a statement about the ones already there as much as the next one.
case watchTimeWeeklyKind, watchTimeMonthlyKind:
if !prefs.WatchTimeDigest {
continue
}
}
filtered = append(filtered, notification)
}
return filtered
}
+296
View File
@@ -0,0 +1,296 @@
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")
}
// 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"`
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,
) map[string]store.WatchTimeTotals {
if !s.tracearrEnabled() || len(accounts) == 0 {
return nil
}
location := s.householdLocation()
now := time.Now()
totals, 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
}
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{}
}
return attributeWatchTime(accounts, totals, identities)
}
// summariseWatchTime turns the store's row into what the console reads, including the case
// where there is no row.
func summariseWatchTime(totals store.WatchTimeTotals, matched bool) watchTimeSummary {
if !matched {
return watchTimeSummary{}
}
return watchTimeSummary{
Matched: true, Username: totals.Username,
WeekMs: totals.WeekMs, 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{}
}
+217
View File
@@ -0,0 +1,217 @@
package api
import (
"context"
"fmt"
"time"
"github.com/ponzischeme89/memby/server/internal/scheduler"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The weekly viewing summary, delivered as a personal notification rather than as a service
// alert: a service alert is the house being told something, and how long somebody watched is
// nobody else's news. It therefore lands in My Alerts, follows the person to whichever
// television they sign into, and is dismissed the way every other notification there is.
//
// Two things make this safe to run hourly. The *source key* is the only thing preventing a
// repeat — `watch-time:weekly:2026-W33` is written ON CONFLICT DO NOTHING, so a container
// restarted three times on a Sunday evening delivers one summary, and a gateway that was
// switched off all evening still delivers it the next hour it is up. And every window it
// reports is computed from a household-local calendar rather than by subtracting hours, so a
// daylight-saving change cannot move a week boundary underneath it.
const (
watchTimeWeeklyKind = "watch-time-week"
watchTimeMonthlyKind = "watch-time-month"
)
// RegisterWatchTimeTasks declares the digest. Registered beside the housekeeping jobs so the
// console lists it, an operator can run it by hand, and its last run is visible — which for a
// job that fires once a week is the difference between "it has not sent anything" and "it has
// not run".
func (s *Server) RegisterWatchTimeTasks(sched *scheduler.Scheduler) {
if sched == nil {
return
}
sched.Register(scheduler.Task{
ID: "watch-time-digest",
Name: "Weekly watch-time summary",
Group: "Notifications",
Description: fmt.Sprintf(
"Sends each viewer their week-to-date and month-to-date viewing on %s evening, "+
"and a summary of the month just gone once it ends. Needs Tracearr.",
digestWeekday),
// Hourly rather than daily: the send window is an evening in the household's own
// time, and a daily task would have to be lucky to land inside it.
Interval: time.Hour,
Timeout: 5 * time.Minute,
Run: s.runWatchTimeDigest,
})
}
func (s *Server) runWatchTimeDigest(ctx context.Context) (string, error) {
// Three refusals before any work, in the order that costs least. A household with no
// Tracearr has nothing to report; an operator who has switched the feature off has said
// so for the whole house; and outside the send window there is nothing due.
if !s.tracearrEnabled() {
return "", nil
}
if !s.featureEnabled(ctx, featureWatchTimeDigest) {
return "", nil
}
location := s.householdLocation()
now := time.Now()
weekly := weeklyDigestDue(now, location)
monthly := monthlyDigestDue(now, location)
if !weekly && !monthly {
return "", nil
}
accounts, err := s.store.KnownUsers(ctx)
if err != nil {
return "", fmt.Errorf("watch-time digest: read viewers: %w", err)
}
if len(accounts) == 0 {
return "", nil
}
identities, err := s.store.TracearrIdentities(ctx)
if err != nil {
// Same trade the console makes: the username join still works, so only a viewer
// renamed in one system loses their summary rather than everybody losing theirs.
s.log.Warn("Tracearr identity map unavailable for watch-time digest", "error", err)
identities = map[string]store.RecommendationIdentity{}
}
sent := 0
if weekly {
count, err := s.sendWeeklyWatchTime(ctx, now, location, accounts, identities)
if err != nil {
return "", err
}
sent += count
}
if monthly {
count, err := s.sendMonthlyWatchTime(ctx, now, location, accounts, identities)
if err != nil {
return "", err
}
sent += count
}
// An empty detail keeps a job that runs every hour out of the operator's notification
// feed every hour; see scheduler.announce.
if sent == 0 {
return "", nil
}
if sent == 1 {
return "1 viewing summary sent", nil
}
return fmt.Sprintf("%d viewing summaries sent", sent), nil
}
func (s *Server) sendWeeklyWatchTime(
ctx context.Context,
now time.Time,
location *time.Location,
accounts []store.KnownUser,
identities map[string]store.RecommendationIdentity,
) (int, error) {
weekStart := weekStartIn(now, location)
week, err := s.store.TracearrWatchTimeRange(ctx, weekStart, now)
if err != nil {
return 0, fmt.Errorf("watch-time digest: week: %w", err)
}
month, err := s.store.TracearrWatchTimeRange(ctx, monthStartIn(now, location), now)
if err != nil {
return 0, fmt.Errorf("watch-time digest: month: %w", err)
}
weekByID, weekByName := indexWatchTimeRanges(week)
monthByID, monthByName := indexWatchTimeRanges(month)
key := "watch-time:weekly:" + weekKey(now, location)
eventAt := now
sent := 0
for _, account := range accounts {
identity := identities[account.ID]
watched := lookupWatchTimeRange(weekByID, weekByName, identity, account.Username)
total := time.Duration(watched.Ms) * time.Millisecond
if total < watchTimeDigestFloor {
continue
}
if !s.watchTimeDigestWanted(ctx, account.ID) {
continue
}
monthWatched := lookupWatchTimeRange(monthByID, monthByName, identity, account.Username)
message := weeklyDigestMessage(
total, time.Duration(monthWatched.Ms)*time.Millisecond, watched.TopTitle)
if err := s.store.UpsertNotification(
ctx, account.ID, key, watchTimeWeeklyKind, "", "Your week in Memby", message, &eventAt,
); err != nil {
s.log.Warn("weekly watch-time summary failed", "user", account.ID, "error", err)
continue
}
sent++
}
if sent > 0 {
s.log.Info("weekly watch-time summaries sent", "viewers", sent, "week", weekKey(now, location))
}
return sent, nil
}
func (s *Server) sendMonthlyWatchTime(
ctx context.Context,
now time.Time,
location *time.Location,
accounts []store.KnownUser,
identities map[string]store.RecommendationIdentity,
) (int, error) {
from, to, monthID := previousMonth(now, location)
windows, err := s.store.TracearrWatchTimeRange(ctx, from, to)
if err != nil {
return 0, fmt.Errorf("watch-time digest: previous month: %w", err)
}
byID, byName := indexWatchTimeRanges(windows)
key := "watch-time:monthly:" + monthID
monthName := from.Format("January")
eventAt := to
sent := 0
for _, account := range accounts {
identity := identities[account.ID]
watched := lookupWatchTimeRange(byID, byName, identity, account.Username)
total := time.Duration(watched.Ms) * time.Millisecond
if total < watchTimeDigestFloor {
continue
}
if !s.watchTimeDigestWanted(ctx, account.ID) {
continue
}
message := monthlyDigestMessage(total, monthName, watched.TopTitle)
if err := s.store.UpsertNotification(
ctx, account.ID, key, watchTimeMonthlyKind,
"", monthName+" in Memby", message, &eventAt,
); err != nil {
s.log.Warn("monthly watch-time summary failed", "user", account.ID, "error", err)
continue
}
sent++
}
if sent > 0 {
s.log.Info("monthly watch-time summaries sent", "viewers", sent, "month", monthID)
}
return sent, nil
}
// watchTimeDigestWanted reads the viewer's own switches. A preference that will not load is
// read as "not now" rather than as consent: this is a notification about somebody's own
// habits, and sending one to a person who may have declined it is the worse of the two
// mistakes.
func (s *Server) watchTimeDigestWanted(ctx context.Context, userID string) bool {
prefs, err := s.notificationPreferencesFor(ctx, userID)
if err != nil {
s.log.Warn("notification preferences unavailable for watch-time digest",
"user", userID, "error", err)
return false
}
return prefs.Enabled && prefs.WatchTimeDigest
}
+252
View File
@@ -0,0 +1,252 @@
package api
import (
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// Auckland is the household this was written for, and the reason every boundary below is
// computed from a calendar rather than by subtracting hours: it moves twice a year.
func auckland(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("Pacific/Auckland")
if err != nil {
t.Skip("no timezone database on this machine")
}
return location
}
func TestWeekStartsOnTheLocalMonday(t *testing.T) {
location := auckland(t)
// Thursday 20 August 2026, local afternoon.
now := time.Date(2026, 8, 20, 15, 30, 0, 0, location)
start := weekStartIn(now, location)
if start.Weekday() != time.Monday {
t.Fatalf("week began on %s, want Monday", start.Weekday())
}
if got, want := start.Format("2006-01-02 15:04"), "2026-08-17 00:00"; got != want {
t.Fatalf("week start = %s, want %s", got, want)
}
}
func TestMondayIsItsOwnWeekStart(t *testing.T) {
location := auckland(t)
now := time.Date(2026, 8, 17, 0, 1, 0, 0, location)
if got := weekStartIn(now, location); !got.Equal(time.Date(2026, 8, 17, 0, 0, 0, 0, location)) {
t.Fatalf("Monday's week start = %s, want the same midnight", got)
}
}
// Sunday is the far end of the week, and reading it as day zero would report a whole week of
// viewing as the coming one's — on the very evening the summary is sent.
func TestSundayBelongsToTheWeekThatIsEnding(t *testing.T) {
location := auckland(t)
now := time.Date(2026, 8, 23, 20, 30, 0, 0, location) // Sunday evening
if got, want := weekStartIn(now, location).Format("2006-01-02"), "2026-08-17"; got != want {
t.Fatalf("Sunday's week start = %s, want %s", got, want)
}
}
// The week containing New Zealand's daylight-saving change is 25 hours long, and a boundary
// computed by subtracting 7*24h would land an hour inside the previous Sunday.
func TestAWeekSpanningTheClockChangeStillBeginsAtMidnight(t *testing.T) {
location := auckland(t)
// Daylight saving ends on the first Sunday of April; 5 April 2026 in this zone.
now := time.Date(2026, 4, 5, 21, 0, 0, 0, location)
start := weekStartIn(now, location)
if got, want := start.Format("2006-01-02 15:04"), "2026-03-30 00:00"; got != want {
t.Fatalf("week start across the clock change = %s, want %s", got, want)
}
}
func TestMonthStartIsLocalMidnightOnTheFirst(t *testing.T) {
location := auckland(t)
now := time.Date(2026, 8, 20, 15, 30, 0, 0, location)
if got, want := monthStartIn(now, location).Format("2006-01-02 15:04"), "2026-08-01 00:00"; got != want {
t.Fatalf("month start = %s, want %s", got, want)
}
}
func TestPreviousMonthIsClosedAtBothEnds(t *testing.T) {
location := auckland(t)
now := time.Date(2026, 1, 3, 21, 0, 0, 0, location)
from, to, key := previousMonth(now, location)
if got := from.Format("2006-01-02"); got != "2025-12-01" {
t.Fatalf("previous month began %s, want 2025-12-01", got)
}
if got := to.Format("2006-01-02"); got != "2026-01-01" {
t.Fatalf("previous month ended %s, want 2026-01-01", got)
}
if key != "2025-12" {
t.Fatalf("previous month key = %q, want 2025-12", key)
}
}
// The source key is the only thing preventing a repeat, so two different weeks must never
// produce the same one — the end of December being where a plain year-and-week would collide.
func TestWeekKeysAreDistinctAcrossTheNewYear(t *testing.T) {
location := auckland(t)
seen := map[string]string{}
for day := 0; day < 21; day++ {
now := time.Date(2025, 12, 22, 20, 0, 0, 0, location).AddDate(0, 0, day)
key := weekKey(now, location)
week := weekStartIn(now, location).Format("2006-01-02")
if previous, ok := seen[key]; ok && previous != week {
t.Fatalf("key %q names both the week of %s and the week of %s", key, previous, week)
}
seen[key] = week
}
if len(seen) != 3 {
t.Fatalf("21 days produced %d week keys, want 3", len(seen))
}
}
func TestTheWeeklySummaryIsDueOnlyOnSundayEvening(t *testing.T) {
location := auckland(t)
cases := []struct {
name string
when time.Time
want bool
}{
{"Sunday evening", time.Date(2026, 8, 23, 20, 0, 0, 0, location), true},
{"later on Sunday night", time.Date(2026, 8, 23, 23, 59, 0, 0, location), true},
{"Sunday afternoon", time.Date(2026, 8, 23, 15, 0, 0, 0, location), false},
{"Monday evening", time.Date(2026, 8, 24, 20, 0, 0, 0, location), false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := weeklyDigestDue(tc.when, location); got != tc.want {
t.Fatalf("weeklyDigestDue = %v, want %v", got, tc.want)
}
})
}
}
// A summary is worded, not printed. These are the cases somebody actually reads.
func TestWatchDurationWording(t *testing.T) {
cases := []struct {
in time.Duration
want string
}{
{0, "no time"},
{20 * time.Second, "no time"},
{40 * time.Second, "1 min"},
{45 * time.Minute, "45 min"},
{time.Hour, "1 hour"},
{3 * time.Hour, "3 hours"},
{3*time.Hour + 12*time.Minute, "3h 12m"},
{3*time.Hour + 12*time.Minute + 40*time.Second, "3h 13m"},
}
for _, tc := range cases {
if got := formatWatchDuration(tc.in); got != tc.want {
t.Fatalf("formatWatchDuration(%s) = %q, want %q", tc.in, got, tc.want)
}
}
}
// The month is dropped when it would repeat the week, which is what the first week of a month
// looks like — two identical figures in one sentence read as a fault.
func TestTheWeeklyMessageOmitsAMonthThatSaysNothingNew(t *testing.T) {
same := weeklyDigestMessage(2*time.Hour, 2*time.Hour, "")
if want := "You watched 2 hours this week."; same != want {
t.Fatalf("message = %q, want %q", same, want)
}
more := weeklyDigestMessage(2*time.Hour, 9*time.Hour, "Severance")
want := "You watched 2 hours this week, and 9 hours so far this month. Mostly Severance."
if more != want {
t.Fatalf("message = %q, want %q", more, want)
}
}
func TestTheMonthlyMessageNamesItsMonth(t *testing.T) {
got := monthlyDigestMessage(14*time.Hour+30*time.Minute, "December", "The Bear")
want := "You watched 14h 30m in December. Most of it on The Bear."
if got != want {
t.Fatalf("message = %q, want %q", got, want)
}
}
// A recorded Tracearr id outranks the name, so a viewer renamed in one system keeps their
// figures instead of quietly reporting zero.
func TestWatchTimeIsAttributedByRecordedIdentityBeforeName(t *testing.T) {
accounts := []watchTimeAccount{{ID: "emby-1", Username: "matt"}}
totals := []store.WatchTimeTotals{
{TracearrUserID: "tr-1", Username: "matthew", WeekMs: 90},
{TracearrUserID: "tr-2", Username: "matt", WeekMs: 5},
}
identities := map[string]store.RecommendationIdentity{
"emby-1": {TracearrUserID: "tr-1", Username: "matthew"},
}
got := attributeWatchTime(accounts, totals, identities)
if got["emby-1"].WeekMs != 90 {
t.Fatalf("attributed %d ms, want the renamed identity's 90", got["emby-1"].WeekMs)
}
}
func TestWatchTimeFallsBackToACaseInsensitiveName(t *testing.T) {
accounts := []watchTimeAccount{{ID: "emby-1", Username: "Matt"}}
totals := []store.WatchTimeTotals{{Username: "matt", WeekMs: 42}}
got := attributeWatchTime(accounts, totals, nil)
if got["emby-1"].WeekMs != 42 {
t.Fatalf("attributed %d ms, want 42", got["emby-1"].WeekMs)
}
}
// Somebody Tracearr has never heard of gets no entry at all, which is what lets the console
// say "not available" rather than drawing a row of confident zeroes.
func TestAnUnmatchedAccountIsAbsentRatherThanZero(t *testing.T) {
accounts := []watchTimeAccount{{ID: "emby-9", Username: "nobody"}}
got := attributeWatchTime(accounts, []store.WatchTimeTotals{{Username: "matt"}}, nil)
if _, ok := got["emby-9"]; ok {
t.Fatal("an unmatched account was attributed watch time")
}
if summary := summariseWatchTime(store.WatchTimeTotals{}, false); summary.Matched {
t.Fatal("an unmatched summary claimed a match")
}
}
func TestARangeIsLookedUpTheSameTwoWays(t *testing.T) {
windows := []store.WatchTimeRange{
{TracearrUserID: "tr-1", Username: "matthew", Ms: 900, TopTitle: "Severance"},
{TracearrUserID: "tr-2", Username: "sam", Ms: 60, TopTitle: "Bluey"},
}
byID, byName := indexWatchTimeRanges(windows)
renamed := lookupWatchTimeRange(byID, byName, store.RecommendationIdentity{TracearrUserID: "tr-1"}, "matt")
if renamed.Ms != 900 {
t.Fatalf("id lookup found %d ms, want 900", renamed.Ms)
}
byNameOnly := lookupWatchTimeRange(byID, byName, store.RecommendationIdentity{}, "SAM")
if byNameOnly.TopTitle != "Bluey" {
t.Fatalf("name lookup found %q, want Bluey", byNameOnly.TopTitle)
}
missing := lookupWatchTimeRange(byID, byName, store.RecommendationIdentity{}, "nobody")
if missing.Ms != 0 || missing.TopTitle != "" {
t.Fatalf("an unknown viewer resolved to %+v, want the zero window", missing)
}
}
// A summary somebody has switched off is withdrawn from the list they already have, and the
// unrelated kinds beside it are untouched.
func TestTurningTheSummaryOffHidesTheOnesAlreadySent(t *testing.T) {
notifications := []store.UserNotification{
{Kind: watchTimeWeeklyKind}, {Kind: watchTimeMonthlyKind},
{Kind: "show-return"}, {Kind: "other"},
}
prefs := store.DefaultNotificationPreferences()
prefs.WatchTimeDigest = false
filtered := filterStoredNotifications(notifications, prefs)
if len(filtered) != 2 {
t.Fatalf("kept %d notifications, want 2", len(filtered))
}
for _, notification := range filtered {
if notification.Kind == watchTimeWeeklyKind || notification.Kind == watchTimeMonthlyKind {
t.Fatalf("a watch-time summary survived the switch being off")
}
}
if kept := filterStoredNotifications(notifications, store.DefaultNotificationPreferences()); len(kept) != 4 {
t.Fatalf("the default preferences kept %d of 4 notifications", len(kept))
}
}