This commit is contained in:
ponzischeme89
2026-08-18 08:41:48 +12:00
parent 1da91e40a1
commit 36d171e51b
50 changed files with 4972 additions and 377 deletions
+15 -1
View File
@@ -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]),
+12 -5
View File
@@ -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 {
+53
View File
@@ -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",
+13
View File
@@ -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
}
+296
View File
@@ -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{}
}
+217
View File
@@ -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
}
+252
View File
@@ -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
View File
@@ -1 +1 @@
0.1.52
0.1.53
+217
View File
@@ -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
View File
@@ -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
+54
View File
@@ -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)")
}
}
+14 -6
View File
@@ -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)
+18 -9
View File
@@ -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
+5
View File
@@ -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
+180
View File
@@ -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()
}