0.2.75
This commit is contained in:
@@ -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]),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.1.52
|
||||
0.1.53
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The corpus harness: the only test in this package that reads a media file.
|
||||
//
|
||||
// Every other test here pins a pure function against numbers written by hand, which is the
|
||||
// right shape for a changepoint rule and no shape at all for the question that actually
|
||||
// matters — does this find the credits. That question needs media with a known answer, so
|
||||
// this runs the detector over a corpus whose ground truth was fixed when the clips were
|
||||
// built (`testdata/gen_corpus.sh`) rather than annotated afterwards by looking at what the
|
||||
// detector said.
|
||||
//
|
||||
// It is skipped unless MEMBY_CREDITS_CORPUS points at such a directory, because the corpus
|
||||
// is media and media does not belong in the repository. Run it as:
|
||||
//
|
||||
// MEMBY_CREDITS_CORPUS=/path/to/corpus go test ./internal/credits -run Corpus -v
|
||||
//
|
||||
// The synthetic corpus is deliberately not a substitute for real episodes. What it is good
|
||||
// for is the structural cases — credits over footage, a dark scene before the roll, a
|
||||
// negative with no credits at all — where the failure is a property of the rule rather than
|
||||
// of any particular show, and where a real library gives you one example a fortnight.
|
||||
|
||||
// truthEntry is one clip and the frame its credits genuinely begin at. A negative carries
|
||||
// -1, which is a claim in its own right: the detector must find nothing.
|
||||
type truthEntry struct {
|
||||
Clip string `json:"clip"`
|
||||
CreditsStartMs int64 `json:"creditsStartMs"`
|
||||
}
|
||||
|
||||
// corpusResult is one clip's outcome, kept apart from the printing so a future run can emit
|
||||
// something other than a table without disturbing the measurement.
|
||||
type corpusResult struct {
|
||||
Clip string
|
||||
TruthMs int64
|
||||
Detected bool
|
||||
StartMs int64
|
||||
// PtsStartMs is the same answer read from the stream's own timestamps rather than from
|
||||
// the sampling arithmetic. Where the two disagree the arithmetic is what is wrong.
|
||||
Confidence float64
|
||||
ErrorMs int64
|
||||
Frames int
|
||||
Elapsed time.Duration
|
||||
Verdict string
|
||||
}
|
||||
|
||||
func TestCorpusVisualDetector(t *testing.T) {
|
||||
dir := strings.TrimSpace(os.Getenv("MEMBY_CREDITS_CORPUS"))
|
||||
if dir == "" {
|
||||
t.Skip("set MEMBY_CREDITS_CORPUS to a corpus directory to run the media benchmark")
|
||||
}
|
||||
sampler := &Sampler{Binary: os.Getenv("MEMBY_CREDITS_FFMPEG"), Timeout: 2 * time.Minute}
|
||||
if !sampler.Available() {
|
||||
t.Skip("ffmpeg is not on the path")
|
||||
}
|
||||
|
||||
truth := loadTruth(t, dir)
|
||||
detector := &VisualDetector{Sampler: sampler}
|
||||
|
||||
results := make([]corpusResult, 0, len(truth))
|
||||
for _, entry := range truth {
|
||||
path := filepath.Join(dir, entry.Clip+".mp4")
|
||||
runtimeMs, err := probeRuntimeMs(path)
|
||||
if err != nil {
|
||||
t.Fatalf("probe %s: %v", entry.Clip, err)
|
||||
}
|
||||
detection, err := detector.Detect(context.Background(), MediaInfo{
|
||||
URL: path,
|
||||
RuntimeMs: runtimeMs,
|
||||
Window: GenericTailWindow(runtimeMs),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("detect %s: %v", entry.Clip, err)
|
||||
}
|
||||
results = append(results, scoreClip(entry, detection))
|
||||
}
|
||||
reportCorpus(t, results)
|
||||
}
|
||||
|
||||
// scoreClip turns one detection into a verdict. The four outcomes are kept distinct rather
|
||||
// than collapsed into pass/fail because they cost quite different things: a miss is a button
|
||||
// that never appears, a false positive throws somebody past the end of an episode, and those
|
||||
// are not the same defect however similar the arithmetic looks.
|
||||
func scoreClip(entry truthEntry, detection Detection) corpusResult {
|
||||
result := corpusResult{
|
||||
Clip: entry.Clip,
|
||||
TruthMs: entry.CreditsStartMs,
|
||||
Detected: detection.Found,
|
||||
StartMs: detection.StartMs,
|
||||
Confidence: detection.Confidence,
|
||||
Frames: detection.FramesSampled,
|
||||
Elapsed: detection.Elapsed,
|
||||
}
|
||||
negative := entry.CreditsStartMs < 0
|
||||
switch {
|
||||
case negative && !detection.Found:
|
||||
result.Verdict = "ok (correctly found nothing)"
|
||||
case negative && detection.Found:
|
||||
result.Verdict = "FALSE POSITIVE"
|
||||
case !detection.Found:
|
||||
result.Verdict = "MISS"
|
||||
default:
|
||||
result.ErrorMs = detection.StartMs - entry.CreditsStartMs
|
||||
result.Verdict = bandFor(result.ErrorMs)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// bandFor names the accuracy band an error falls in. The bands are the brief's, and the sign
|
||||
// is kept because early and late are not equally bad: early clips the last line of dialogue,
|
||||
// late shows the viewer the thing they asked to skip.
|
||||
func bandFor(errorMs int64) string {
|
||||
magnitude := errorMs
|
||||
if magnitude < 0 {
|
||||
magnitude = -magnitude
|
||||
}
|
||||
switch {
|
||||
case magnitude <= 100:
|
||||
return "<=100ms"
|
||||
case magnitude <= 500:
|
||||
return "<=500ms"
|
||||
case magnitude <= 1000:
|
||||
return "<=1s"
|
||||
case magnitude <= 4000:
|
||||
return "<=4s"
|
||||
default:
|
||||
return "WRONG"
|
||||
}
|
||||
}
|
||||
|
||||
func reportCorpus(t *testing.T, results []corpusResult) {
|
||||
t.Helper()
|
||||
sort.Slice(results, func(a, b int) bool { return results[a].Clip < results[b].Clip })
|
||||
|
||||
var report strings.Builder
|
||||
fmt.Fprintf(&report, "\n%-28s %9s %9s %9s %6s %7s %7s %s\n",
|
||||
"CLIP", "TRUTH", "FOUND", "ERROR", "CONF", "FRAMES", "TIME", "VERDICT")
|
||||
var (
|
||||
within500, positives, falsePositives, misses int
|
||||
)
|
||||
for _, result := range results {
|
||||
found, errorLabel := "-", "-"
|
||||
if result.Detected {
|
||||
found = formatMs(result.StartMs)
|
||||
if result.TruthMs >= 0 {
|
||||
errorLabel = fmt.Sprintf("%+.3fs", float64(result.ErrorMs)/1000)
|
||||
}
|
||||
}
|
||||
truthLabel := "none"
|
||||
if result.TruthMs >= 0 {
|
||||
truthLabel = formatMs(result.TruthMs)
|
||||
}
|
||||
fmt.Fprintf(&report, "%-28s %9s %9s %9s %6.2f %7d %6.1fs %s\n",
|
||||
result.Clip, truthLabel, found, errorLabel, result.Confidence,
|
||||
result.Frames, result.Elapsed.Seconds(), result.Verdict)
|
||||
|
||||
switch {
|
||||
case result.Verdict == "FALSE POSITIVE":
|
||||
falsePositives++
|
||||
case result.Verdict == "MISS":
|
||||
misses++
|
||||
case result.TruthMs >= 0:
|
||||
positives++
|
||||
if magnitude := result.ErrorMs; magnitude <= 500 && magnitude >= -500 {
|
||||
within500++
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&report, "\n%d/%d located within 500ms, %d missed, %d false positives\n",
|
||||
within500, positives+misses, misses, falsePositives)
|
||||
t.Log(report.String())
|
||||
}
|
||||
|
||||
func loadTruth(t *testing.T, dir string) []truthEntry {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "truth.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read truth: %v", err)
|
||||
}
|
||||
var truth []truthEntry
|
||||
if err := json.Unmarshal(raw, &truth); err != nil {
|
||||
t.Fatalf("parse truth: %v", err)
|
||||
}
|
||||
if len(truth) == 0 {
|
||||
t.Fatal("corpus truth is empty")
|
||||
}
|
||||
return truth
|
||||
}
|
||||
|
||||
func probeRuntimeMs(path string) (int64, error) {
|
||||
out, err := exec.Command("ffprobe", "-v", "error",
|
||||
"-show_entries", "format=duration", "-of", "csv=p=0", path).Output()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
seconds, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(seconds * 1000), nil
|
||||
}
|
||||
|
||||
func formatMs(value int64) string {
|
||||
return fmt.Sprintf("%d:%06.3f", value/60000, float64(value%60000)/1000)
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env bash
|
||||
# Synthetic credits-detection corpus with exact ground truth.
|
||||
#
|
||||
# Every clip is 25fps, 640x360, with audio, and every positive puts credits_start
|
||||
# at exactly frame 1534 = 61.360s. That figure is deliberately not round: at a
|
||||
# 750ms fine-pass interval anchored to the window start, a boundary at 60.000s is
|
||||
# itself a sample point, so a detector sampling on that grid scores a perfect zero
|
||||
# for reasons that have nothing to do with how well it found anything. Off-grid
|
||||
# truth is what makes the reported error the real error.
|
||||
#
|
||||
# Segments are encoded separately and concatenated, so the boundary is a genuine
|
||||
# frame boundary rather than something a filter approximated.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
OUT=corpus
|
||||
rm -rf "$OUT" parts
|
||||
mkdir -p "$OUT" parts
|
||||
FONT="C\\:/Windows/Fonts/arial.ttf"
|
||||
ENC="-c:v libx264 -preset veryfast -pix_fmt yuv420p -g 50 -r 25 -c:a aac -b:a 96k -ar 48000 -ac 2"
|
||||
Q="-hide_banner -loglevel error -y"
|
||||
|
||||
# Frame-exact durations. 1534 frames at 25fps is 61.360s.
|
||||
D_PROG=61.36 # programme before the credits
|
||||
D_PROG_A=46.36 # programme before a dark closing scene
|
||||
D_DARK=15.00 # the dark closing scene
|
||||
D_CRED=30.00
|
||||
D_CRED_SHORT=20.00
|
||||
D_POST=10.00
|
||||
D_DARKTAIL=30.00
|
||||
TRUTH_MS=61360
|
||||
|
||||
cat > parts/credits.txt <<'EOF'
|
||||
DIRECTED BY
|
||||
ALEX MERRIWEATHER
|
||||
WRITTEN BY
|
||||
JORDAN HALE
|
||||
PRODUCED BY
|
||||
SAM OKONKWO
|
||||
CAST
|
||||
RILEY BRENNAN
|
||||
DANA VOSS
|
||||
KIT ARMITAGE
|
||||
MARGO SEELEY
|
||||
DIRECTOR OF PHOTOGRAPHY
|
||||
NOOR HADDAD
|
||||
EDITED BY
|
||||
TOBY LINDQVIST
|
||||
MUSIC BY
|
||||
PRIYA RAGHAVAN
|
||||
PRODUCTION DESIGNER
|
||||
ELLIOT SHAW
|
||||
COSTUME DESIGNER
|
||||
FRANCES ADEYEMI
|
||||
CASTING BY
|
||||
WES TANAKA
|
||||
UNIT PRODUCTION MANAGER
|
||||
HANNAH DELACROIX
|
||||
FIRST ASSISTANT DIRECTOR
|
||||
OSCAR BRENNAN
|
||||
EOF
|
||||
|
||||
# Audio beds. Programme is broadband and non-stationary the way speech is;
|
||||
# credits are a steady two-note pad. The distinction a detector can find here is
|
||||
# stationarity and spectral shape, which is the same distinction real end-credit
|
||||
# music offers against dialogue — synthetic, but not a different mechanism.
|
||||
A_PROG="anoisesrc=color=brown:amplitude=0.35:r=48000,tremolo=f=3.5:d=0.8"
|
||||
A_CRED="sine=frequency=220:r=48000,volume=0.3"
|
||||
|
||||
# $1 out $2 duration $3 video lavfi $4 audio lavfi [$5 extra -vf]
|
||||
seg() {
|
||||
local extra="${5:-null}"
|
||||
ffmpeg $Q -f lavfi -i "$3" -f lavfi -i "$4" -t "$2" -vf "$extra" $ENC "parts/$1.mp4"
|
||||
}
|
||||
|
||||
# scrolling credits over a supplied background. $1 out $2 dur $3 vsrc $4 fontcolour
|
||||
credits_over() {
|
||||
seg "$1" "$2" "$3" "$A_CRED" \
|
||||
"drawtext=fontfile='$FONT':textfile=parts/credits.txt:fontcolor=$4:fontsize=15:line_spacing=10:x=(w-tw)/2:y=h-35*t"
|
||||
}
|
||||
|
||||
join() { # $1 out name, rest: part names
|
||||
local out="$1"; shift
|
||||
: > parts/list.txt
|
||||
# Relative to the list file's own directory: a POSIX $(pwd) from Git Bash is
|
||||
# not a path native ffmpeg can open.
|
||||
for p in "$@"; do echo "file '$p.mp4'" >> parts/list.txt; done
|
||||
ffmpeg $Q -f concat -safe 0 -i parts/list.txt -c copy "$OUT/$out.mp4"
|
||||
}
|
||||
|
||||
PROG="testsrc2=s=640x360:r=25"
|
||||
DARKPROG="color=c=#0d0d10:s=640x360:r=25"
|
||||
BLACK="color=c=black:s=640x360:r=25"
|
||||
WHITE="color=c=white:s=640x360:r=25"
|
||||
|
||||
echo "building parts..."
|
||||
seg prog $D_PROG "$PROG" "$A_PROG"
|
||||
seg progA $D_PROG_A "$PROG" "$A_PROG"
|
||||
seg dark15 $D_DARK "$DARKPROG" "$A_PROG"
|
||||
seg darktail $D_DARKTAIL "$DARKPROG" "$A_PROG"
|
||||
seg post10 $D_POST "$PROG" "$A_PROG"
|
||||
seg progfade $D_PROG "$PROG" "$A_PROG" "fade=t=out:st=59.86:d=1.5"
|
||||
|
||||
credits_over cred_black $D_CRED "$BLACK" white
|
||||
credits_over cred_short $D_CRED_SHORT "$BLACK" white
|
||||
credits_over cred_over $D_CRED "$PROG" white
|
||||
credits_over cred_white $D_CRED "$WHITE" black
|
||||
|
||||
# static centred card credits (no scroll)
|
||||
seg cred_static $D_CRED "$BLACK" "$A_CRED" \
|
||||
"drawtext=fontfile='$FONT':textfile=parts/credits.txt:fontcolor=white:fontsize=13:line_spacing=6:x=(w-tw)/2:y=(h-th)/2"
|
||||
|
||||
echo "assembling clips..."
|
||||
join hard-cut-black prog cred_black
|
||||
join fade-to-black progfade cred_black
|
||||
join credits-over-footage prog cred_over
|
||||
join bright-credits prog cred_white
|
||||
join static-card-credits prog cred_static
|
||||
join dark-scene-then-credits progA dark15 cred_black
|
||||
join short-credits-postcred prog cred_short post10
|
||||
join negative-dark-ending progA dark15 darktail
|
||||
|
||||
# Ground truth travels with the corpus rather than being written into the
|
||||
# harness: a truth table kept apart from the media it describes is one that
|
||||
# silently stops matching when a clip is regenerated.
|
||||
{
|
||||
echo '['
|
||||
first=1
|
||||
for f in "$OUT"/*.mp4; do
|
||||
n=$(basename "$f" .mp4)
|
||||
[ $first -eq 1 ] || echo ','
|
||||
first=0
|
||||
if [ "$n" = "negative-dark-ending" ]; then
|
||||
printf ' {"clip":"%s","creditsStartMs":-1}' "$n"
|
||||
else
|
||||
printf ' {"clip":"%s","creditsStartMs":%s}' "$n" "$TRUTH_MS"
|
||||
fi
|
||||
done
|
||||
echo; echo ']'
|
||||
} > "$OUT/truth.json"
|
||||
|
||||
echo
|
||||
printf '%-28s %-9s %s\n' CLIP DURATION TRUTH
|
||||
for f in "$OUT"/*.mp4; do
|
||||
n=$(basename "$f" .mp4)
|
||||
d=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f")
|
||||
if [ "$n" = "negative-dark-ending" ]; then t="none"; else t="61.360"; fi
|
||||
printf '%-28s %-9.2f %s\n' "$n" "$d" "$t"
|
||||
done
|
||||
@@ -0,0 +1,54 @@
|
||||
package credits
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDebugDump(t *testing.T) {
|
||||
path := os.Getenv("MEMBY_DEBUG_CLIP")
|
||||
if path == "" {
|
||||
t.Skip("no clip")
|
||||
}
|
||||
s := &Sampler{Timeout: 2 * time.Minute}
|
||||
coarse, err := s.Sample(context.Background(), path, 0, 91380*time.Millisecond, coarseInterval)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("coarse frames=%d", len(coarse))
|
||||
for i, f := range coarse {
|
||||
t.Logf(" [%2d] pos=%6d mean=%.3f dark=%.3f edge=%.4f var=%.4f diff=%.4f score=%.3f",
|
||||
i, f.PositionMs, f.Mean, f.DarkFraction, f.EdgeDensity, f.Variance, f.Diff, creditScore(f))
|
||||
}
|
||||
idx, sep, ok := findTransition(coarse, coarseInterval)
|
||||
t.Logf("coarse transition idx=%d pos=%v sep=%.3f ok=%v", idx, func() int64 {
|
||||
if ok {
|
||||
return coarse[idx].PositionMs
|
||||
}
|
||||
return -1
|
||||
}(), sep, ok)
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
start := time.Duration(coarse[idx].PositionMs) * time.Millisecond
|
||||
from := start - fineSpan
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
to := start + fineSpan
|
||||
fine, err := s.Sample(context.Background(), path, from, to, fineInterval)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fmt.Println("fine from", from, "to", to, "frames", len(fine))
|
||||
fidx, fsep, fok := findTransition(fine, fineInterval)
|
||||
if fok {
|
||||
t.Logf("fine transition idx=%d pos=%d sep=%.3f", fidx, fine[fidx].PositionMs, fsep)
|
||||
} else {
|
||||
t.Logf("fine transition: NONE (fine pass did not refine)")
|
||||
}
|
||||
}
|
||||
@@ -72,9 +72,15 @@ type JourneyEvent struct {
|
||||
Feature string `json:"feature"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
ItemID string `json:"itemId,omitempty"`
|
||||
ItemName string `json:"itemName,omitempty"`
|
||||
ItemType string `json:"itemType,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
// PlaySessionID is Emby's id for the stream a playback step is about, and is empty on
|
||||
// every other kind of step. It is what joins a journey to the playback the gateway
|
||||
// already logs, so an operator can tell one viewing of a title from the next without
|
||||
// the two records having to agree on anything but this.
|
||||
PlaySessionID string `json:"playSessionId,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
}
|
||||
|
||||
type AnalyticsUser struct {
|
||||
@@ -306,12 +312,13 @@ func (s *Store) InsertJourneyEvents(ctx context.Context, events []JourneyEvent)
|
||||
batch.Queue(`
|
||||
INSERT INTO journey_events
|
||||
(occurred_at, emby_user_id, journey_id, sequence, category, action, screen,
|
||||
feature, source, target, item_name, item_type, outcome)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
|
||||
feature, source, target, item_id, item_name, item_type, play_session_id, outcome)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
|
||||
ON CONFLICT (emby_user_id, journey_id, sequence) DO NOTHING`,
|
||||
event.OccurredAt, event.UserID, event.JourneyID, event.Sequence,
|
||||
event.Category, event.Action, event.Screen, event.Feature, event.Source,
|
||||
event.Target, event.ItemName, event.ItemType, event.Outcome)
|
||||
event.Target, event.ItemID, event.ItemName, event.ItemType,
|
||||
event.PlaySessionID, event.Outcome)
|
||||
}
|
||||
results := s.pool.SendBatch(ctx, batch)
|
||||
defer results.Close()
|
||||
@@ -464,7 +471,8 @@ func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) (
|
||||
func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time.Time, limit int) ([]JourneyEvent, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, occurred_at, emby_user_id, journey_id, sequence, category, action,
|
||||
screen, feature, source, target, item_name, item_type, outcome
|
||||
screen, feature, source, target, item_id, item_name, item_type,
|
||||
play_session_id, outcome
|
||||
FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
|
||||
ORDER BY occurred_at DESC, sequence DESC LIMIT $3`, userID, since, limit)
|
||||
if err != nil {
|
||||
@@ -474,7 +482,7 @@ func (s *Store) UserJourneyEvents(ctx context.Context, userID string, since time
|
||||
out := []JourneyEvent{}
|
||||
for rows.Next() {
|
||||
var v JourneyEvent
|
||||
if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemName, &v.ItemType, &v.Outcome); err != nil {
|
||||
if err := rows.Scan(&v.ID, &v.OccurredAt, &v.UserID, &v.JourneyID, &v.Sequence, &v.Category, &v.Action, &v.Screen, &v.Feature, &v.Source, &v.Target, &v.ItemID, &v.ItemName, &v.ItemType, &v.PlaySessionID, &v.Outcome); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
|
||||
@@ -23,13 +23,19 @@ type NotificationPreferences struct {
|
||||
UpdateAlerts bool `json:"updateAlerts"`
|
||||
LibraryAlerts bool `json:"libraryAlerts"`
|
||||
SystemAlerts bool `json:"systemAlerts"`
|
||||
LeadDays int `json:"leadDays"`
|
||||
// WatchTimeDigest is the weekly viewing summary. It is its own switch rather than part
|
||||
// of SystemAlerts because it is the only notification here that is about the viewer
|
||||
// rather than about the library or the server, and somebody who wants to be told a show
|
||||
// was cancelled may well not want to be told how long they spent watching it.
|
||||
WatchTimeDigest bool `json:"watchTimeDigest"`
|
||||
LeadDays int `json:"leadDays"`
|
||||
}
|
||||
|
||||
func DefaultNotificationPreferences() NotificationPreferences {
|
||||
return NotificationPreferences{
|
||||
Enabled: true, ShowReturnAlerts: true, SonarrAlerts: true, RadarrAlerts: true,
|
||||
UpdateAlerts: true, LibraryAlerts: true, SystemAlerts: true, LeadDays: 7,
|
||||
UpdateAlerts: true, LibraryAlerts: true, SystemAlerts: true,
|
||||
WatchTimeDigest: true, LeadDays: 7,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,10 +216,11 @@ func (s *Store) NotificationPreferences(ctx context.Context, userID string) (Not
|
||||
prefs := DefaultNotificationPreferences()
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
||||
update_alerts, library_alerts, system_alerts, lead_days
|
||||
update_alerts, library_alerts, system_alerts, watch_time_digest, lead_days
|
||||
FROM user_notification_preferences WHERE emby_user_id = $1`, userID).
|
||||
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts, &prefs.RadarrAlerts,
|
||||
&prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts, &prefs.LeadDays)
|
||||
&prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts,
|
||||
&prefs.WatchTimeDigest, &prefs.LeadDays)
|
||||
if err != nil && !isNoRows(err) {
|
||||
return prefs, fmt.Errorf("store: notification preferences: %w", err)
|
||||
}
|
||||
@@ -232,8 +239,8 @@ func (s *Store) SetNotificationPreferences(
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_notification_preferences
|
||||
(emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
||||
update_alerts, library_alerts, system_alerts, lead_days)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
update_alerts, library_alerts, system_alerts, watch_time_digest, lead_days)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (emby_user_id) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
show_return_alerts = EXCLUDED.show_return_alerts,
|
||||
@@ -242,17 +249,19 @@ func (s *Store) SetNotificationPreferences(
|
||||
update_alerts = EXCLUDED.update_alerts,
|
||||
library_alerts = EXCLUDED.library_alerts,
|
||||
system_alerts = EXCLUDED.system_alerts,
|
||||
watch_time_digest = EXCLUDED.watch_time_digest,
|
||||
lead_days = EXCLUDED.lead_days,
|
||||
updated_at = now()`,
|
||||
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.SonarrAlerts, prefs.RadarrAlerts,
|
||||
prefs.UpdateAlerts, prefs.LibraryAlerts, prefs.SystemAlerts, prefs.LeadDays)
|
||||
prefs.UpdateAlerts, prefs.LibraryAlerts, prefs.SystemAlerts, prefs.WatchTimeDigest,
|
||||
prefs.LeadDays)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]NotificationPreferences, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT emby_user_id, enabled, show_return_alerts, sonarr_alerts, radarr_alerts,
|
||||
update_alerts, library_alerts, system_alerts, lead_days
|
||||
update_alerts, library_alerts, system_alerts, watch_time_digest, lead_days
|
||||
FROM user_notification_preferences`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list notification preferences: %w", err)
|
||||
@@ -264,7 +273,7 @@ func (s *Store) AllNotificationPreferences(ctx context.Context) (map[string]Noti
|
||||
var userID string
|
||||
if err := rows.Scan(&userID, &prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.SonarrAlerts,
|
||||
&prefs.RadarrAlerts, &prefs.UpdateAlerts, &prefs.LibraryAlerts, &prefs.SystemAlerts,
|
||||
&prefs.LeadDays); err != nil {
|
||||
&prefs.WatchTimeDigest, &prefs.LeadDays); err != nil {
|
||||
return nil, fmt.Errorf("store: scan notification preferences: %w", err)
|
||||
}
|
||||
result[userID] = prefs
|
||||
|
||||
@@ -199,6 +199,9 @@ CREATE TABLE IF NOT EXISTS journey_events (
|
||||
);
|
||||
|
||||
ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS item_name TEXT NOT NULL DEFAULT '';
|
||||
-- Emby's id for the stream a playback step describes. Empty on every other kind of step,
|
||||
-- and on every row written before playback steps carried one.
|
||||
ALTER TABLE journey_events ADD COLUMN IF NOT EXISTS play_session_id TEXT NOT NULL DEFAULT '';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS journey_events_journey_sequence_idx
|
||||
ON journey_events (emby_user_id, journey_id, sequence);
|
||||
@@ -243,6 +246,7 @@ CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
update_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
library_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
system_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
watch_time_digest BOOLEAN NOT NULL DEFAULT true,
|
||||
lead_days INT NOT NULL DEFAULT 7 CHECK (lead_days BETWEEN 1 AND 30),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -255,6 +259,7 @@ ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS radarr_alerts
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS update_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS library_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS system_alerts BOOLEAN NOT NULL DEFAULT true;
|
||||
ALTER TABLE user_notification_preferences ADD COLUMN IF NOT EXISTS watch_time_digest BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- Notifications are materialised so read/dismissed state follows the user to every TV.
|
||||
-- source_key is deterministic, preventing the same return date from being announced
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Watch time is read out of tracearr_sessions rather than stored again.
|
||||
//
|
||||
// Tracearr is already the household's record of who watched what and for how long, and the
|
||||
// import that feeds recommendations has written every one of those rows into Postgres — so
|
||||
// a second table counting minutes would be a copy of a copy, wrong the moment Tracearr
|
||||
// corrects a session and needing its own reconciliation to stay honest. Everything below is
|
||||
// therefore a query, and the only cost of adding this feature is the reading.
|
||||
//
|
||||
// watchedMsExpr is the one definition of "how long was this actually watched", and it exists
|
||||
// in exactly this one place so the console's figure and the digest's figure can never
|
||||
// disagree. Tracearr reports two overlapping numbers — durationMs, which is aggregate watch
|
||||
// time, and progressMs, the furthest point reached — and neither is reliably the larger, so
|
||||
// the greater of the two is taken. It is then capped at the title's own length, because a
|
||||
// session somebody left running against a title they rewound through can otherwise report
|
||||
// more watching than the programme contains. A zero total means Tracearr did not say how
|
||||
// long the title was, and NULLIF hands that case to LEAST as NULL, which Postgres ignores —
|
||||
// so an unknown length caps nothing rather than capping everything to zero.
|
||||
const watchedMsExpr = `GREATEST(LEAST(GREATEST(duration_ms, progress_ms), NULLIF(total_duration_ms, 0)), 0)`
|
||||
|
||||
// watchedTitleExpr names what was watched the way a person would. An episode is reported as
|
||||
// its series, for the same reason Session.TitleKey does it: "three hours of Severance" is
|
||||
// the useful sentence, and "forty minutes of Chikhai Bardo" is a fact about one episode
|
||||
// nobody asked about.
|
||||
const watchedTitleExpr = `CASE WHEN lower(media_type) = 'episode' AND show_title <> ''
|
||||
THEN show_title ELSE media_title END`
|
||||
|
||||
// WatchTimeTotals is one Tracearr identity's viewing, in the three windows the console
|
||||
// shows at once. Username is carried beside the id because the id is what Tracearr calls
|
||||
// somebody and the name is the only thing that can be matched against an Emby account.
|
||||
type WatchTimeTotals struct {
|
||||
TracearrUserID string `json:"tracearrUserId,omitempty"`
|
||||
Username string `json:"username"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// WatchTimeRange is one identity's viewing inside a closed window, with the title most of it
|
||||
// went on. Kept apart from WatchTimeTotals because a window that has *ended* is a different
|
||||
// question from a running total: it is what the month-end summary reports, and it is the only
|
||||
// one of the two for which naming a top title is worth an extra pass over the rows.
|
||||
type WatchTimeRange struct {
|
||||
TracearrUserID string
|
||||
Username string
|
||||
Ms int64
|
||||
Sessions int
|
||||
TopTitle string
|
||||
TopTitleMs int64
|
||||
}
|
||||
|
||||
// TracearrWatchTime totals the two running windows and the lifetime figure in one pass.
|
||||
//
|
||||
// One query rather than three because this is read while an operator waits for the accounts
|
||||
// page, and because the three numbers must describe the same instant — three queries against
|
||||
// a table an import is writing into could report a week larger than the month containing it.
|
||||
//
|
||||
// The boundaries are passed in rather than computed here: a week begins on the household's
|
||||
// local Monday, and the store has no idea which timezone the household keeps.
|
||||
func (s *Store) TracearrWatchTime(
|
||||
ctx context.Context,
|
||||
weekStart, monthStart time.Time,
|
||||
) ([]WatchTimeTotals, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT
|
||||
max(tracearr_user_id) AS tracearr_user_id,
|
||||
max(username) AS username,
|
||||
coalesce(sum(`+watchedMsExpr+`) FILTER (WHERE started_at >= $1), 0)::bigint AS week_ms,
|
||||
coalesce(sum(`+watchedMsExpr+`) FILTER (WHERE started_at >= $2), 0)::bigint AS month_ms,
|
||||
coalesce(sum(`+watchedMsExpr+`), 0)::bigint AS total_ms,
|
||||
count(*) FILTER (WHERE started_at >= $1) AS week_sessions,
|
||||
count(*) FILTER (WHERE started_at >= $2) AS month_sessions,
|
||||
max(started_at) AS last_watched_at
|
||||
FROM tracearr_sessions
|
||||
WHERE username <> ''
|
||||
GROUP BY lower(username)
|
||||
ORDER BY total_ms DESC`,
|
||||
weekStart, monthStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: tracearr watch time: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []WatchTimeTotals{}
|
||||
for rows.Next() {
|
||||
var totals WatchTimeTotals
|
||||
if err := rows.Scan(
|
||||
&totals.TracearrUserID, &totals.Username, &totals.WeekMs, &totals.MonthMs,
|
||||
&totals.TotalMs, &totals.WeekSessions, &totals.MonthSessions, &totals.LastWatchedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan tracearr watch time: %w", err)
|
||||
}
|
||||
out = append(out, totals)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TracearrWatchTimeRange totals a closed window, half-open on the right so two adjacent
|
||||
// months can never both claim a session started on the stroke of midnight.
|
||||
func (s *Store) TracearrWatchTimeRange(
|
||||
ctx context.Context,
|
||||
from, to time.Time,
|
||||
) ([]WatchTimeRange, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH watched AS (
|
||||
SELECT lower(username) AS user_key, tracearr_user_id, username,
|
||||
`+watchedTitleExpr+` AS title,
|
||||
`+watchedMsExpr+` AS ms
|
||||
FROM tracearr_sessions
|
||||
WHERE username <> '' AND started_at >= $1 AND started_at < $2
|
||||
),
|
||||
totals AS (
|
||||
SELECT user_key, max(tracearr_user_id) AS tracearr_user_id, max(username) AS username,
|
||||
coalesce(sum(ms), 0)::bigint AS ms, count(*) AS sessions
|
||||
FROM watched GROUP BY user_key
|
||||
),
|
||||
titles AS (
|
||||
SELECT user_key, title, coalesce(sum(ms), 0)::bigint AS ms
|
||||
FROM watched WHERE title <> '' GROUP BY user_key, title
|
||||
),
|
||||
tops AS (
|
||||
SELECT DISTINCT ON (user_key) user_key, title, ms
|
||||
FROM titles ORDER BY user_key, ms DESC, title
|
||||
)
|
||||
SELECT totals.tracearr_user_id, totals.username, totals.ms, totals.sessions,
|
||||
coalesce(tops.title, ''), coalesce(tops.ms, 0)
|
||||
FROM totals LEFT JOIN tops ON tops.user_key = totals.user_key
|
||||
ORDER BY totals.ms DESC`,
|
||||
from, to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: tracearr watch time range: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []WatchTimeRange{}
|
||||
for rows.Next() {
|
||||
var window WatchTimeRange
|
||||
if err := rows.Scan(
|
||||
&window.TracearrUserID, &window.Username, &window.Ms,
|
||||
&window.Sessions, &window.TopTitle, &window.TopTitleMs,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan tracearr watch time range: %w", err)
|
||||
}
|
||||
out = append(out, window)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TracearrIdentities is every Emby account the recommendation builder has already matched to
|
||||
// a Tracearr one. Watch time is attributed by username, which is the identity the two systems
|
||||
// genuinely share — but a household that has renamed somebody in one and not the other would
|
||||
// silently lose their figures, and this map is what lets the id carry them instead.
|
||||
func (s *Store) TracearrIdentities(ctx context.Context) (map[string]RecommendationIdentity, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT emby_user_id, tracearr_user_id, tracearr_username
|
||||
FROM recommendation_user_profiles
|
||||
WHERE tracearr_user_id <> '' OR tracearr_username <> ''`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: tracearr identities: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]RecommendationIdentity{}
|
||||
for rows.Next() {
|
||||
var embyUserID string
|
||||
var identity RecommendationIdentity
|
||||
if err := rows.Scan(&embyUserID, &identity.TracearrUserID, &identity.Username); err != nil {
|
||||
return nil, fmt.Errorf("store: scan tracearr identity: %w", err)
|
||||
}
|
||||
out[embyUserID] = identity
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user