Files

238 lines
8.2 KiB
Go
Raw Permalink Normal View History

2026-08-18 08:41:48 +12:00
package api
import (
"context"
"fmt"
"time"
2026-08-19 06:57:59 +12:00
"github.com/ponzischeme89/memby/server/internal/notify"
2026-08-18 08:41:48 +12:00
"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)
2026-08-19 06:57:59 +12:00
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")
2026-08-18 08:41:48 +12:00
continue
}
2026-08-19 06:57:59 +12:00
if s.notifyUser(ctx, notification) {
sent++
}
2026-08-18 08:41:48 +12:00
}
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)
2026-08-19 06:57:59 +12:00
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")
2026-08-18 08:41:48 +12:00
continue
}
2026-08-19 06:57:59 +12:00
if s.notifyUser(ctx, notification) {
sent++
}
2026-08-18 08:41:48 +12:00
}
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
}