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