package api import ( "context" "fmt" "time" "github.com/ponzischeme89/memby/server/internal/notify" "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 } monthWatched := lookupWatchTimeRange(monthByID, monthByName, identity, account.Username) message := weeklyDigestMessage( total, time.Duration(monthWatched.Ms)*time.Millisecond, watched.TopTitle) notification := notify.Notification{ Kind: watchTimeWeeklyKind, Source: notifySourceWatchTime, UserID: account.ID, Username: account.Username, Title: "Your week in Memby", Body: message, SourceKey: key, EventAt: &eventAt, Metadata: map[string]any{"watchedMs": watched.Ms, "topTitle": watched.TopTitle}, } // The floor above is a judgement about the news; this is a judgement about the // person, and only the second one is worth recording. "You have summaries switched // off" is the answer to somebody reporting that they never get one, and it is not // findable anywhere else. if !s.watchTimeDigestWanted(ctx, account.ID) { s.declineUser(ctx, notification, "this viewer has watch-time summaries switched off") continue } if s.notifyUser(ctx, notification) { 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 } message := monthlyDigestMessage(total, monthName, watched.TopTitle) notification := notify.Notification{ Kind: watchTimeMonthlyKind, Source: notifySourceWatchTime, UserID: account.ID, Username: account.Username, Title: monthName + " in Memby", Body: message, SourceKey: key, EventAt: &eventAt, Metadata: map[string]any{"watchedMs": watched.Ms, "topTitle": watched.TopTitle, "month": monthID}, } if !s.watchTimeDigestWanted(ctx, account.ID) { s.declineUser(ctx, notification, "this viewer has watch-time summaries switched off") continue } if s.notifyUser(ctx, notification) { 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 }