0.2.75
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user