This commit is contained in:
ponzischeme89
2026-08-28 23:00:02 +12:00
parent 3e89036f7b
commit d5632e844a
66 changed files with 2870 additions and 689 deletions
+1
View File
@@ -74,6 +74,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
mux.Handle("GET /admin/api/requests", s.adminAuth(s.handleAdminRequests))
mux.Handle("GET /admin/api/media-reports", s.adminAuth(s.handleAdminMediaReports))
mux.Handle("POST /admin/api/media-reports/{id}/status", s.adminAuth(s.handleAdminMediaReportStatus))
mux.Handle("GET /admin/api/sonarr-request-policy", s.adminAuth(s.handleAdminSonarrRequestPolicy))
+2 -2
View File
@@ -105,7 +105,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
for _, account := range accounts {
identifiers = append(identifiers, watchTimeAccount{ID: account.ID, Username: account.Username})
}
watchTime := s.watchTimeForAccounts(r.Context(), identifiers)
watchTime, priorWeekWatchTime := s.watchTimeForAccounts(r.Context(), identifiers)
result := make([]adminMembyAccount, 0, len(accounts))
for _, account := range accounts {
@@ -123,7 +123,7 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
}
watched, matchedWatchTime := watchTime[account.ID]
result = append(result, adminMembyAccount{
WatchTime: summariseWatchTime(watched, matchedWatchTime),
WatchTime: summariseWatchTime(watched, matchedWatchTime, priorWeekWatchTime[account.ID]),
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
Initials: stringPreference(accountSettings.Preferences, "profileInitials"),
ShortName: stringPreference(accountSettings.Preferences, "shortName"),
@@ -44,6 +44,9 @@ func (s *Server) gatewaySettingsResponse() adminGatewaySettingsResponse {
EmbyHealthSeconds: int(s.embyHealthInterval() / time.Second),
SlowRequestMillis: effectiveSlowRequestMillis(s.slowRequestThreshold()),
LibrarySyncMinutes: int(s.LibrarySyncInterval() / time.Minute),
HomeTTLSeconds: int(s.homeTTL() / time.Second),
RecommendTTLHours: int(s.recommendTTL() / time.Hour),
ForYouRebuildHour: s.forYouRebuildHour(),
},
LogLevels: store.GatewayLogLevels,
NotificationDisplays: store.GatewayNotificationDisplays,
@@ -146,6 +149,15 @@ func gatewaySettingsChanges(before, after store.GatewaySettings) string {
if before.LibrarySyncMinutes != after.LibrarySyncMinutes {
changes = append(changes, "library sweep interval")
}
if before.HomeTTLSeconds != after.HomeTTLSeconds {
changes = append(changes, "home cache lifetime")
}
if before.RecommendTTLHours != after.RecommendTTLHours {
changes = append(changes, "recommendation cache lifetime")
}
if !sameIntPointer(before.ForYouRebuildHour, after.ForYouRebuildHour) {
changes = append(changes, "For You rebuild hour")
}
switch len(changes) {
case 0:
return ""
@@ -156,6 +168,13 @@ func gatewaySettingsChanges(before, after store.GatewaySettings) string {
}
}
func sameIntPointer(a, b *int) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func joinPhrase(values []string) string {
switch len(values) {
case 0:
+16 -1
View File
@@ -167,7 +167,22 @@ func adminPreviewData() map[string]any {
map[string]any{"from": "details", "to": "playback", "count": 18},
},
},
"/admin/api/requests": map[string]any{"requests": []any{}},
"/admin/api/requests": map[string]any{"requests": []any{
map[string]any{"userId": "u-1", "username": "matt", "mediaType": "movie",
"foreignId": 693134, "title": "Dune: Part Two", "year": 2024,
"requestedAt": stamp(2 * time.Hour), "status": "downloading",
"statusLabel": "Downloading", "statusDetail": "About 12 minutes left",
"progress": 64},
map[string]any{"userId": "u-2", "username": "sam", "mediaType": "series",
"foreignId": 371980, "title": "Severance", "year": 2022,
"requestedAt": stamp(30 * time.Hour), "status": "available",
"statusLabel": "Ready to watch", "statusDetail": "In your library",
"embyItemId": "abc123"},
map[string]any{"userId": "u-1", "username": "matt", "mediaType": "movie",
"foreignId": 1032823, "title": "Memory", "year": 2023,
"requestedAt": stamp(9 * 24 * time.Hour), "status": "requested",
"statusLabel": "Requested", "statusDetail": "Searching for a copy"},
}},
// A prefix among the terms and an unattributed row in the log, because both are
// ordinary here and a preview showing neither would not be a preview of this page.
"/admin/api/searches": map[string]any{
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The console's window on what the household has actually asked for.
//
// The overview already reports RequestUsage — a count and a last-asked time per viewer —
// which answers "who uses the feature" and nothing about "what did they ask for". This is
// the table underneath that number: every stored ask, newest first, with the person
// attached and the same derived status the viewer's own page shows, so an operator can see
// that three people are waiting on the same film or that a request has been stuck
// searching for a week.
const adminRequestsLimit = store.MediaRequestSweepLimit
// adminMediaRequest is one row of that table: an ask, who made it, and what has become of
// it. The status half is derived per read by decorateRequests, exactly as it is for the
// viewer, so the console and the television never disagree about a title's state.
type adminMediaRequest struct {
UserID string `json:"userId"`
Username string `json:"username,omitempty"`
myRequest
}
type adminRequestsResponse struct {
Requests []adminMediaRequest `json:"requests"`
}
func (s *Server) handleAdminRequests(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
owned, err := s.store.AllMediaRequests(ctx, adminRequestsLimit)
if err != nil {
s.loggerFor(ctx).Error("admin media requests read failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read media requests")
return
}
// An optional filter to what one viewer asked for, applied here rather than in SQL: the
// list is already bounded and small, and AllMediaRequests is the one read the ready
// sweep also uses.
if userID := strings.TrimSpace(r.URL.Query().Get("userId")); userID != "" {
filtered := owned[:0]
for _, req := range owned {
if req.UserID == userID {
filtered = append(filtered, req)
}
}
owned = filtered
}
stored := make([]store.MediaRequest, len(owned))
for i := range owned {
stored[i] = owned[i].MediaRequest
}
// One decoration pass for the whole household. The *arr catalogues it consults are
// cached for the day and shared, so this is the same cost as one viewer opening their
// page. The console is the operator's tool, so it always sees the full progress
// vocabulary.
cards := s.decorateRequests(ctx, stored, true)
// A name an ask cannot be attributed to costs the column and nothing else — the id
// still tells one requester from another, and the titles are the point of the page.
names := map[string]string{}
if users, err := s.store.KnownUsers(ctx); err == nil {
for _, user := range users {
if user.Username != "" {
names[user.ID] = user.Username
}
}
} else {
s.loggerFor(ctx).Warn("media request owners unresolved", "error", err)
}
rows := make([]adminMediaRequest, len(owned))
for i := range owned {
rows[i] = adminMediaRequest{
UserID: owned[i].UserID,
Username: names[owned[i].UserID],
myRequest: cards[i],
}
}
writeJSON(w, http.StatusOK, adminRequestsResponse{Requests: rows})
}
+22
View File
@@ -947,4 +947,26 @@ func TestToRowEventValidatesAndClamps(t *testing.T) {
t.Fatalf("user should come from the session, got %q", event.UserID)
}
})
t.Run("rejects free text in the controlled fields", func(t *testing.T) {
cases := []rowEventPayload{
{RowID: "row with spaces", Event: "focus"},
{RowID: "r", RowKind: "MOVIES; drop table", Event: "focus"},
{RowID: "r", Event: "focus", ItemID: "not/an/id"},
{RowID: strings.Repeat("x", 101), Event: "focus"},
}
for _, payload := range cases {
if _, ok := toRowEvent(payload, "u", now); ok {
t.Fatalf("expected %+v to be dropped", payload)
}
}
})
t.Run("keeps a well-formed curated row id", func(t *testing.T) {
event, ok := toRowEvent(
rowEventPayload{RowID: "curated:movies:genre:science-fiction", RowKind: "movies", Event: "impression"}, "u", now)
if !ok || event.RowID != "curated:movies:genre:science-fiction" {
t.Fatalf("a normal composed row id should pass, got ok=%v event=%+v", ok, event)
}
})
}
+9
View File
@@ -210,6 +210,15 @@ func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.Ro
if payload.RowID == "" {
return store.RowEvent{}, false
}
// Row id, kind and item id are controlled vocabulary the television composes, never
// free text — so an event carrying anything else in them was misassembled, and a
// pathological value would distort the aggregates the console reads. Drop it whole,
// as the journey path does.
if !safeAnalyticsValue(payload.RowID, 100) ||
!safeAnalyticsValue(payload.RowKind, 40) ||
!safeAnalyticsValue(payload.ItemID, 100) {
return store.RowEvent{}, false
}
switch payload.Event {
case store.RowEventImpression, store.RowEventFocus, store.RowEventSelect:
default:
+5 -2
View File
@@ -132,7 +132,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
Actor: req.Username, Target: req.DeviceName,
Link: "/admin/logins",
Metadata: adminevents.Meta(map[string]any{
"deviceId": req.DeviceID, "ip": requestClientIP(r),
"deviceId": req.DeviceID, "ip": s.resolveClientIP(r).String(),
}),
})
writeError(w, http.StatusUnauthorized, "sign-in failed")
@@ -205,14 +205,17 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Now that the session exists, the request line this call ends with can name it too.
identify(r.Context(), sess)
origin := s.resolveClientIP(r)
s.loggerFor(r.Context()).Info("signed in",
"emby_user", sess.EmbyUserID,
"device_id", sess.DeviceID,
"protocol", clientLogValue(sess.ClientProtocol),
"replaced_session", len(created.ReplacedHash) > 0,
"client_ip", origin.String(),
"client_ip_via", origin.Via,
)
address := requestClientIP(r)
address := origin.String()
s.recordLogin(r, store.LoginEvent{
EmbyUserID: sess.EmbyUserID, Username: sess.Username,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
+109
View File
@@ -0,0 +1,109 @@
package api
import (
"net"
"net/http"
"net/netip"
"strings"
"github.com/ponzischeme89/memby/server/internal/config"
)
// clientIP is where a request came from and how that was worked out. Via is one of
// "forwarded", "real-ip", "socket" or "none". It is for operational logging and the
// admin console's directory only — never authentication or access control.
type clientIP struct {
Addr string
Via string
}
// String is the address, or "unknown" when none could be determined, matching what the
// login history and the trailer log previously recorded.
func (c clientIP) String() string {
if c.Addr == "" {
return "unknown"
}
return c.Addr
}
// resolveClientIP works out the originating address of an incoming request. It believes
// X-Forwarded-For and X-Real-IP only when the immediate peer is a configured trusted
// proxy, so a client that reaches the gateway directly cannot spoof its address with a
// header.
func (s *Server) resolveClientIP(r *http.Request) clientIP {
trusted := s.cfg.TrustedProxies
if trusted == nil {
trusted = config.DefaultTrustedProxyRanges()
}
return clientIPFrom(r.RemoteAddr, r.Header, trusted)
}
func clientIPFrom(remoteAddr string, header http.Header, trusted []netip.Prefix) clientIP {
socket := parseHostAddr(remoteAddr)
if !socket.IsValid() {
return clientIP{Via: "none"}
}
fromSocket := clientIP{Addr: socket.String(), Via: "socket"}
if !prefixesContain(trusted, socket) {
// The peer is not a known proxy, so nothing it forwarded is believed.
return fromSocket
}
// X-Forwarded-For grows by one entry per hop, so the rightmost is the nearest
// proxy. Walking right to left, the first entry that is not itself a trusted proxy
// is the originating client.
forwarded := forwardedChain(header)
for i := len(forwarded) - 1; i >= 0; i-- {
if !prefixesContain(trusted, forwarded[i]) {
return clientIP{Addr: forwarded[i].String(), Via: "forwarded"}
}
}
// Every forwarded hop was itself trusted. If there were any, the leftmost is the
// genuine origin — a direct LAN client behind the household proxy. Otherwise fall
// back to a single X-Real-IP, then to the socket.
if len(forwarded) > 0 {
return clientIP{Addr: forwarded[0].String(), Via: "forwarded"}
}
if realIP := parseAddr(header.Get("X-Real-IP")); realIP.IsValid() {
return clientIP{Addr: realIP.String(), Via: "real-ip"}
}
return fromSocket
}
func parseHostAddr(remoteAddr string) netip.Addr {
remoteAddr = strings.TrimSpace(remoteAddr)
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
remoteAddr = host
}
return parseAddr(remoteAddr)
}
func parseAddr(value string) netip.Addr {
addr, err := netip.ParseAddr(strings.TrimSpace(value))
if err != nil {
return netip.Addr{}
}
return addr.Unmap()
}
func forwardedChain(header http.Header) []netip.Addr {
var out []netip.Addr
for _, value := range header.Values("X-Forwarded-For") {
for _, part := range strings.Split(value, ",") {
if addr := parseAddr(part); addr.IsValid() {
out = append(out, addr)
}
}
}
return out
}
func prefixesContain(prefixes []netip.Prefix, addr netip.Addr) bool {
for _, prefix := range prefixes {
if prefix.Contains(addr) {
return true
}
}
return false
}
+90
View File
@@ -0,0 +1,90 @@
package api
import (
"net/http"
"net/netip"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
)
func TestClientIPFrom(t *testing.T) {
trusted := config.DefaultTrustedProxyRanges()
tests := []struct {
name string
remoteAddr string
headers map[string]string
wantAddr string
wantVia string
}{
{
name: "direct public client, no proxy headers believed",
remoteAddr: "203.0.113.9:52344",
headers: map[string]string{"X-Forwarded-For": "10.0.0.9"},
wantAddr: "203.0.113.9",
wantVia: "socket",
},
{
name: "through the household proxy, real client in X-Forwarded-For",
remoteAddr: "10.0.0.2:41000",
headers: map[string]string{"X-Forwarded-For": "203.0.113.42, 10.0.0.2"},
wantAddr: "203.0.113.42",
wantVia: "forwarded",
},
{
name: "direct LAN client behind the proxy still shows its LAN address",
remoteAddr: "10.0.0.2:41000",
headers: map[string]string{"X-Forwarded-For": "10.0.0.50"},
wantAddr: "10.0.0.50",
wantVia: "forwarded",
},
{
name: "proxy sets only X-Real-IP",
remoteAddr: "192.168.1.1:8443",
headers: map[string]string{"X-Real-IP": "198.51.100.7"},
wantAddr: "198.51.100.7",
wantVia: "real-ip",
},
{
name: "trusted proxy with no forwarding headers",
remoteAddr: "127.0.0.1:5000",
wantAddr: "127.0.0.1",
wantVia: "socket",
},
{
name: "spoofed X-Real-IP from an untrusted client is ignored",
remoteAddr: "203.0.113.9:1000",
headers: map[string]string{"X-Real-IP": "10.0.0.1"},
wantAddr: "203.0.113.9",
wantVia: "socket",
},
{
name: "unparseable remote address",
remoteAddr: "garbage",
wantAddr: "unknown",
wantVia: "none",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
header := http.Header{}
for key, value := range test.headers {
header.Set(key, value)
}
got := clientIPFrom(test.remoteAddr, header, trusted)
if got.String() != test.wantAddr || got.Via != test.wantVia {
t.Fatalf("clientIPFrom = %+v, want addr %q via %q", got, test.wantAddr, test.wantVia)
}
})
}
}
func TestClientIPFromTrustsNothingWhenListEmpty(t *testing.T) {
header := http.Header{"X-Forwarded-For": {"203.0.113.1"}}
got := clientIPFrom("10.0.0.2:5000", header, []netip.Prefix{})
if got.String() != "10.0.0.2" || got.Via != "socket" {
t.Fatalf("clientIPFrom with no trusted proxies = %+v, want 10.0.0.2 via socket", got)
}
}
+9 -5
View File
@@ -89,11 +89,11 @@ func intPtr(v int) *int { return &v }
// once, here, rather than the admin console guessing at it independently and drifting from
// what a television actually understands.
type rowTypeDefinition struct {
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description"`
Component string `json:"component"`
DataSource string `json:"dataSource"`
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description"`
Component string `json:"component"`
DataSource string `json:"dataSource"`
// Pages lists which of Home/Movies/TV this type may be placed on. A row whose type
// is not valid for the page it is being saved to is exactly the "unsupported row/page
// combination" the visual editor exists to make impossible.
@@ -173,6 +173,10 @@ func validateSectionDefinitions(key string, raw json.RawMessage) error {
if definition.MaxItems < 0 || definition.MaxItems > 100 {
return errors.New(key + ": row " + id + " has an invalid maximum item count")
}
if definition.Layout != "" &&
definition.Layout != config.SectionLayoutPoster && definition.Layout != config.SectionLayoutThumb {
return errors.New(key + ": row " + id + " has an invalid card layout")
}
if strings.TrimSpace(definition.Component) == "" {
return errors.New(key + ": row " + id + " needs a component")
}
+31
View File
@@ -163,6 +163,31 @@ func (s *Server) LibrarySyncInterval() time.Duration {
s.cfg.SyncInterval)
}
// homeTTL and recommendTTL are "the override, or what was deployed" for the two cache
// lifetimes an operator can reach. They cannot be switched off, so a non-positive stored
// value falls through to the deployed duration.
func (s *Server) homeTTL() time.Duration {
if seconds := s.gatewaySettings.get().HomeTTLSeconds; seconds > 0 {
return time.Duration(seconds) * time.Second
}
return s.cfg.HomeTTL
}
func (s *Server) recommendTTL() time.Duration {
if hours := s.gatewaySettings.get().RecommendTTLHours; hours > 0 {
return time.Duration(hours) * time.Hour
}
return s.cfg.RecommendTTL
}
// forYouRebuildHour is the household-local hour the daily For You rebuild is due at.
func (s *Server) forYouRebuildHour() int {
if hour := s.gatewaySettings.get().ForYouRebuildHour; hour != nil {
return *hour
}
return s.cfg.ForYouRebuildHour
}
// overrideWindow reads one of the three settings that can be switched off: a negative
// value is off, zero is "whatever was deployed", anything else is the override in the
// given unit.
@@ -191,6 +216,9 @@ type deployedGatewaySettings struct {
EmbyHealthSeconds int `json:"embyHealthSeconds"`
SlowRequestMillis int `json:"slowRequestMillis"`
LibrarySyncMinutes int `json:"librarySyncMinutes"`
HomeTTLSeconds int `json:"homeTtlSeconds"`
RecommendTTLHours int `json:"recommendTtlHours"`
ForYouRebuildHour int `json:"forYouRebuildHour"`
}
func (s *Server) deployedSettings() deployedGatewaySettings {
@@ -208,6 +236,9 @@ func (s *Server) deployedSettings() deployedGatewaySettings {
EmbyHealthSeconds: int(s.cfg.EmbyHealthInterval / time.Second),
SlowRequestMillis: int(s.cfg.SlowRequestThreshold / time.Millisecond),
LibrarySyncMinutes: int(s.cfg.SyncInterval / time.Minute),
HomeTTLSeconds: int(s.cfg.HomeTTL / time.Second),
RecommendTTLHours: int(s.cfg.RecommendTTL / time.Hour),
ForYouRebuildHour: s.cfg.ForYouRebuildHour,
}
}
@@ -65,6 +65,36 @@ func TestNotificationDisplayDefaultsToHomeOnly(t *testing.T) {
}
}
func TestCacheOverridesFallBackToDeployed(t *testing.T) {
s := &Server{}
s.cfg.HomeTTL = 60 * time.Second
s.cfg.RecommendTTL = 24 * time.Hour
s.cfg.ForYouRebuildHour = 4
if got := s.homeTTL(); got != 60*time.Second {
t.Fatalf("unset home TTL should be the deployed value, got %s", got)
}
if got := s.recommendTTL(); got != 24*time.Hour {
t.Fatalf("unset recommend TTL should be the deployed value, got %s", got)
}
if got := s.forYouRebuildHour(); got != 4 {
t.Fatalf("unset rebuild hour should be the deployed value, got %d", got)
}
midnight := 0
s.gatewaySettings.set(store.GatewaySettings{
HomeTTLSeconds: 30, RecommendTTLHours: 6, ForYouRebuildHour: &midnight,
})
if got := s.homeTTL(); got != 30*time.Second {
t.Fatalf("home TTL override not applied, got %s", got)
}
if got := s.recommendTTL(); got != 6*time.Hour {
t.Fatalf("recommend TTL override not applied, got %s", got)
}
if got := s.forYouRebuildHour(); got != 0 {
t.Fatalf("a rebuild-hour override of midnight must win over the deployed 4, got %d", got)
}
}
func TestLevelNameCoversTheVocabulary(t *testing.T) {
for _, level := range store.GatewayLogLevels {
if got := levelName(parseTestLevel(t, level)); got != level {
+27 -1
View File
@@ -135,6 +135,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
forYouRows []recommend.Row
forYouRowStale bool
seriesPlayed map[string]time.Time
surfacedReady []store.SurfacedReadyRequest
ranking rankingInputs
rowStats []store.RowStat
rowStatsOK bool
@@ -286,6 +287,24 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
defer wg.Done()
ranking = s.rankingInputs(ctx, sess.EmbyUserID)
}()
// The requests this viewer has asked for that have just become watchable, to be pinned
// to the front of Continue Watching. Read beside the Emby fan-out, not after it: it is a
// function of the viewer's id alone, and the overwhelmingly common answer is none.
if continueWatching && s.store != nil {
wg.Add(1)
go func() {
defer wg.Done()
ready, err := s.store.SurfacedReadyRequests(ctx, sess.EmbyUserID)
if err != nil {
s.loggerFor(ctx).Warn("surfaced request arrivals unavailable",
"user", sess.EmbyUserID, "error", err)
return
}
mu.Lock()
surfacedReady = ready
mu.Unlock()
}()
}
if s.store != nil {
wg.Add(1)
go func() {
@@ -346,6 +365,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
if continueWatching {
out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow)
}
// A title this viewer requested that has just arrived is pinned to the front of the row
// (or tagged in place if it is already there), so the request journey ends where they
// look for something to watch rather than on the My Requests page.
if continueWatching && len(surfacedReady) > 0 {
out.ContinueWatching = s.injectRequestReady(ctx, cred, out.ContinueWatching, surfacedReady)
}
// Recommendations are read from their own long-lived cache. A miss means this
// response ships without them and a rebuild starts in the background — the home
@@ -391,6 +416,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
out.Rows = s.personalizeTitlesWith(out.Rows, ranking)
out.Rows = personalizeRowsByTitleScores(selectPersonalizedRows(out.Rows))
out.Rows = deduplicateRows(out.Rows)
out.Rows = s.applyRowLayouts(ctx, out.Rows)
rank()
// Ratings ride on the cards themselves. Only what is already stored is attached, so
// the launcher pays one indexed read rather than a request per poster, and a card
@@ -422,7 +448,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
}
// A partial payload is served but never cached: the next request should retry.
if !out.Partial {
if err := s.cache.Set(ctx, key, body, s.cfg.HomeTTL); err != nil {
if err := s.cache.Set(ctx, key, body, s.homeTTL()); err != nil {
s.loggerFor(ctx).Warn("home cache write failed", "error", err)
}
}
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"context"
"encoding/json"
"strings"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
// sectionDefinitionKeys are the three configuration values that hold a page's row
// composition. A layout override can be set on any of them, and the home payload is the
// single row source for all three browse destinations on the television, so all three are
// merged into one lookup.
var sectionDefinitionKeys = []string{
"home.sectionDefinitions",
"movies.sectionDefinitions",
"tv.sectionDefinitions",
}
// rowLayoutOverrides collects every operator-pinned card shape from the section
// definitions, keyed by the definition id. An invalid or empty layout is not an override.
func rowLayoutOverrides(policy store.FeaturePolicy) map[string]string {
overrides := map[string]string{}
for _, key := range sectionDefinitionKeys {
raw, ok := policy.Values[key]
if !ok {
continue
}
var definitions []config.RemoteSectionDefinition
if json.Unmarshal(raw, &definitions) != nil {
continue
}
for _, definition := range definitions {
switch definition.Layout {
case config.SectionLayoutPoster, config.SectionLayoutThumb:
id := strings.TrimSpace(definition.ID)
if id != "" {
overrides[id] = definition.Layout
}
}
}
}
return overrides
}
// applyRowLayoutOverrides stamps the pinned card shape onto each finished row. A row is
// matched by its exact id first, then by a section id it is a child of ("for-you" pins
// "for-you:home:evening" too) — the same rule applyRemoteHomeSections uses on the
// television to rank a family of rows together. A row that already carries a layout (none
// do today) is left alone. Pure, so it can be pinned by a test.
func applyRowLayoutOverrides(rows []recommend.Row, overrides map[string]string) []recommend.Row {
if len(overrides) == 0 {
return rows
}
for index := range rows {
if rows[index].Layout != "" {
continue
}
if layout, ok := overrides[rows[index].ID]; ok {
rows[index].Layout = layout
continue
}
for section, layout := range overrides {
if strings.HasPrefix(rows[index].ID, section+":") {
rows[index].Layout = layout
break
}
}
}
return rows
}
func (s *Server) applyRowLayouts(ctx context.Context, rows []recommend.Row) []recommend.Row {
return applyRowLayoutOverrides(rows, rowLayoutOverrides(s.currentFeaturePolicy(ctx)))
}
@@ -0,0 +1,61 @@
package api
import (
"encoding/json"
"testing"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestRowLayoutOverridesReadsEveryPage(t *testing.T) {
policy := store.FeaturePolicy{Values: map[string]json.RawMessage{
"home.sectionDefinitions": json.RawMessage(`[
{"id":"favorites","type":"favorites","title":"Favourites","component":"mediaRow","layout":"thumb"},
{"id":"continue","type":"continueWatching","title":"Continue","component":"mediaRow"},
{"id":"broken","type":"custom","title":"x","component":"mediaRow","layout":"sideways"}
]`),
"movies.sectionDefinitions": json.RawMessage(`[
{"id":"library","type":"library","title":"Library","component":"mediaGrid","layout":"poster"}
]`),
}}
got := rowLayoutOverrides(policy)
if got["favorites"] != "thumb" {
t.Fatalf("favorites layout = %q, want thumb", got["favorites"])
}
if got["library"] != "poster" {
t.Fatalf("library layout = %q, want poster", got["library"])
}
if _, ok := got["continue"]; ok {
t.Fatalf("a row with no layout must not appear as an override")
}
if _, ok := got["broken"]; ok {
t.Fatalf("an invalid layout must not appear as an override")
}
}
func TestApplyRowLayoutOverridesMatchesExactAndChildRows(t *testing.T) {
rows := []recommend.Row{
{ID: "favorites"},
{ID: "for-you"},
{ID: "for-you:home:evening"},
{ID: "latest-movies", Layout: "poster"},
}
out := applyRowLayoutOverrides(rows, map[string]string{"favorites": "thumb", "for-you": "poster"})
if out[0].Layout != "thumb" {
t.Fatalf("exact match not applied: %q", out[0].Layout)
}
if out[1].Layout != "poster" || out[2].Layout != "poster" {
t.Fatalf("a section id must pin its child rows too: %q / %q", out[1].Layout, out[2].Layout)
}
if out[3].Layout != "poster" {
t.Fatalf("a row that already carries a layout must be left alone: %q", out[3].Layout)
}
}
func TestApplyRowLayoutOverridesNoOpWithoutOverrides(t *testing.T) {
rows := []recommend.Row{{ID: "favorites"}}
if applyRowLayoutOverrides(rows, nil)[0].Layout != "" {
t.Fatalf("no overrides must leave rows untouched")
}
}
+1 -1
View File
@@ -283,7 +283,7 @@ func (s *Server) runForYouRebuild(ctx context.Context) (scheduler.Outcome, error
if err != nil {
return scheduler.Outcome{}, fmt.Errorf("read For You rebuild state: %w", err)
}
if !forYouRebuildDue(state.LastRebuildAt, now, s.cfg.ForYouRebuildHour) {
if !forYouRebuildDue(state.LastRebuildAt, now, s.forYouRebuildHour()) {
return scheduler.Outcome{}, nil
}
result, err := s.forYou.RebuildAll(ctx, true)
-26
View File
@@ -5,7 +5,6 @@ import (
"crypto/rand"
"encoding/hex"
"log/slog"
"net"
"net/http"
"strings"
@@ -237,28 +236,3 @@ func isPlaybackItemPath(path string) bool {
}
return false
}
// requestClientIP is the viewer-facing address recorded for trailer playback. The first
// Forwarded address is the original client when the gateway is behind its normal reverse
// proxy; direct deployments fall back to RemoteAddr. This value is for operational logs,
// never authentication or access control.
func requestClientIP(r *http.Request) string {
for _, value := range strings.Split(r.Header.Get("X-Forwarded-For"), ",") {
if ip := net.ParseIP(strings.TrimSpace(value)); ip != nil {
return ip.String()
}
}
if ip := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); ip != nil {
return ip.String()
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err == nil {
if ip := net.ParseIP(host); ip != nil {
return ip.String()
}
}
if ip := net.ParseIP(strings.TrimSpace(r.RemoteAddr)); ip != nil {
return ip.String()
}
return "unknown"
}
-9
View File
@@ -73,15 +73,6 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
}
}
func TestRequestClientIPPrefersOriginalForwardedAddress(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/items/42/trailers/report", nil)
request.RemoteAddr = "10.0.0.2:41234"
request.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.2")
if got := requestClientIP(request); got != "203.0.113.9" {
t.Fatalf("client ip = %q", got)
}
}
func TestIdentifyNamesTheViewerAndTelevision(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
request.Header.Set("X-Memby-Version", "0.1.60")
+7 -1
View File
@@ -31,7 +31,13 @@ func (s *Server) recordLogin(r *http.Request, event store.LoginEvent) {
return
}
if event.IPAddress == "" {
event.IPAddress = requestClientIP(r)
origin := s.resolveClientIP(r)
event.IPAddress = origin.String()
// One line per sign-in attempt, so an operator can see which header the address
// came from when a reverse proxy is in front of the gateway. Debug keeps it out
// of the ordinary log while still being there when the question is asked.
s.log.Debug("resolved login client ip",
"ip", origin.String(), "via", origin.Via, "device_id", event.DeviceID)
}
if event.ClientVersion == "" {
event.ClientVersion = clientVersion(r)
+19
View File
@@ -248,6 +248,25 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
"negotiation_duration", time.Since(negotiationStarted).Round(time.Millisecond),
)
// Starting to watch it retires the REQUEST READY pin — the request journey is complete,
// and from here the card is ordinary Continue Watching. Best-effort and detached: the
// viewer is about to watch something regardless of whether this write lands, and the
// age-out sweep is the backstop. itemID is the id the card opened (the series id for a
// series request); target.ID is the resolved movie or episode.
if s.store != nil {
detached := context.WithoutCancel(ctx)
go func() {
cleared, err := s.store.ClearRequestReadySurfaced(detached, sess.EmbyUserID, itemID, target.ID)
if err != nil {
s.loggerFor(detached).Warn("request ready pin not cleared", "error", err)
return
}
if cleared > 0 {
s.invalidateHomeFor(detached, sess.EmbyUserID)
}
}()
}
nextAiringAvailable, nextAiringLabel, nextAiringDayLabel, nextAiringCode :=
s.nextAiringFieldsFor(ctx, target)
writeJSON(w, http.StatusOK, playbackResponse{
+1 -1
View File
@@ -63,7 +63,7 @@ func (s *Server) buildRecommendations(ctx context.Context, sess store.Session) (
// An empty result is cached too: a user with no history should not trigger a full
// rebuild on every single home load.
if raw, err := json.Marshal(rows); err == nil {
if err := s.cache.Set(ctx, cache.RecommendationsKey(sess.EmbyUserID), raw, s.cfg.RecommendTTL); err != nil {
if err := s.cache.Set(ctx, cache.RecommendationsKey(sess.EmbyUserID), raw, s.recommendTTL()); err != nil {
s.loggerFor(ctx).Warn("recommendation cache write failed", "error", err)
}
}
+25
View File
@@ -70,6 +70,15 @@ func (s *Server) runRequestReadyScan(ctx context.Context) (string, error) {
return "", nil
}
// Retire pins nobody has acted on in a fortnight before looking at anything else, so a
// stale REQUEST READY tag is gone from Home within a sweep of its deadline rather than
// leaning on SurfacedReadyRequests' own window clause to keep hiding it.
if expired, err := s.store.ExpireStaleReadyRequests(ctx); err != nil {
s.loggerFor(ctx).Warn("stale request pins not expired", "error", err)
} else {
s.invalidateHomeFor(ctx, expired...)
}
owned, err := s.store.AllMediaRequests(ctx, store.MediaRequestSweepLimit)
if err != nil {
return "", fmt.Errorf("request arrivals: read requests: %w", err)
@@ -112,6 +121,22 @@ func (s *Server) runRequestReadyScan(ctx context.Context) (string, error) {
if s.announceRequestReady(ctx, req, card) {
announced++
}
// Pin it to the front of the requester's Continue Watching row until they play
// it. Only possible once the library has an item id — a title the *arr reports a
// file for but Emby has not imported yet is still worth the notification above,
// but there is nothing for a card to open. MarkRequestReadySurfaced's own guard
// makes the repeat a no-op, so this is safe to call on every observed transition.
if card.EmbyItemID != "" {
if err := s.store.MarkRequestReadySurfaced(
ctx, req.UserID, req.MediaType, req.ForeignID, card.EmbyItemID,
); err != nil {
s.loggerFor(ctx).Warn("request ready pin not recorded",
"user_id", req.UserID, "type", req.MediaType,
"foreign_id", req.ForeignID, "error", err)
} else {
s.invalidateHomeFor(ctx, req.UserID)
}
}
}
if err := s.store.SetMediaRequestStatus(
ctx, req.UserID, req.MediaType, req.ForeignID, card.Status,
+179
View File
@@ -0,0 +1,179 @@
package api
import (
"context"
"encoding/json"
"net/url"
"strings"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
// Pinning a freshly arrived request to the front of Continue Watching.
//
// The notification (requests_ready.go) tells the person who asked that their title has
// landed; this is the other half — putting it where they actually look for something to
// watch. An unwatched film never appears in Continue Watching on its own and a new series
// only shows up if they go hunting, so without this the request journey ends a step short of
// its payoff.
//
// It is deliberately cheap. The only new work on the Home path is one indexed Postgres read
// (SurfacedReadyRequests), and — only when that read is non-empty, which is rare — one
// batched Emby item lookup for the titles not already in the row. No Radarr or Sonarr call
// is ever made here.
const (
// requestReadyField and requestReadyLabelField are what the television reads. The label
// is the server's wording (the MembyAirLabel precedent), so renaming it needs no app
// release; a client that predates the field falls back to its own constant.
requestReadyField = "MembyRequestReady"
requestReadyLabelField = "MembyRequestReadyLabel"
// requestReadyLabel is the tag drawn on the card.
requestReadyLabel = "REQUEST READY"
)
// injectRequestReady tags or prepends the viewer's still-pinned request arrivals in the
// merged Continue Watching list.
//
// - A surfaced title already in the row (the film itself, or the series an episode belongs
// to) is tagged in place — no second card.
// - One not in the row is fetched from Emby and prepended, newest arrival first.
//
// Any failure degrades to the list untouched: the person was already told by notification,
// and a missing pin is a smaller disappointment than a broken launcher.
func (s *Server) injectRequestReady(
ctx context.Context,
cred emby.Credentials,
items []json.RawMessage,
surfaced []store.SurfacedReadyRequest,
) []json.RawMessage {
if len(surfaced) == 0 {
return items
}
missing := missingRequestReady(items, surfaced)
if len(missing) == 0 {
return mergeRequestReady(items, surfaced, nil)
}
ids := make([]string, 0, len(missing))
for _, req := range missing {
ids = append(ids, req.ItemID)
}
fetched, err := s.emby.Items(ctx, cred, rowParams(url.Values{
"Ids": {strings.Join(ids, ",")},
"Recursive": {"true"},
}, fieldsContinue))
if err != nil {
s.loggerFor(ctx).Warn("request ready items unavailable", "error", err)
// The titles already in the row are still worth tagging even when the fetch for the
// rest fails.
return mergeRequestReady(items, surfaced, nil)
}
byID := make(map[string]json.RawMessage, len(fetched.Items))
for _, raw := range fetched.Items {
if id, _, _, _ := continueItemFields(raw, nil); id != "" {
byID[id] = raw
}
}
return mergeRequestReady(items, surfaced, byID)
}
// requestReadyIndex maps every item and series id already in the row to its position.
func requestReadyIndex(items []json.RawMessage) map[string]int {
present := make(map[string]int, len(items))
for index, raw := range items {
id, seriesID, _, _ := continueItemFields(raw, nil)
if id != "" {
present[id] = index
}
if seriesID != "" {
if _, ok := present[seriesID]; !ok {
present[seriesID] = index
}
}
}
return present
}
// missingRequestReady is the surfaced arrivals whose title is not already in the row.
func missingRequestReady(
items []json.RawMessage, surfaced []store.SurfacedReadyRequest,
) []store.SurfacedReadyRequest {
present := requestReadyIndex(items)
missing := make([]store.SurfacedReadyRequest, 0, len(surfaced))
for _, req := range surfaced {
if _, ok := present[req.ItemID]; !ok {
missing = append(missing, req)
}
}
return missing
}
// mergeRequestReady is the pure half: stamp the arrivals already in the row in place, and
// prepend the rest from `fetched` (newest first), stamped. An arrival with no fetched item
// is simply left out — the notification already carried the news.
func mergeRequestReady(
items []json.RawMessage,
surfaced []store.SurfacedReadyRequest,
fetched map[string]json.RawMessage,
) []json.RawMessage {
out := make([]json.RawMessage, len(items))
copy(out, items)
present := requestReadyIndex(out)
var missing []store.SurfacedReadyRequest
for _, req := range surfaced {
if index, ok := present[req.ItemID]; ok {
out[index] = stampRequestReady(out[index])
continue
}
missing = append(missing, req)
}
// surfaced is ordered newest first, so this block keeps that order and the whole block
// goes in front of the existing row.
prepend := make([]json.RawMessage, 0, len(missing))
for _, req := range missing {
if raw, ok := fetched[req.ItemID]; ok {
prepend = append(prepend, stampRequestReady(raw))
}
}
if len(prepend) == 0 {
return out
}
return append(prepend, out...)
}
// stampRequestReady sets the two tag fields on one item's JSON, leaving everything else
// alone. It mirrors decorateItemRatings' approach — decode to a map, set, re-encode — so a
// field the row does not model is preserved.
func stampRequestReady(raw json.RawMessage) json.RawMessage {
var item map[string]any
if err := json.Unmarshal(raw, &item); err != nil || item == nil {
return raw
}
item[requestReadyField] = true
item[requestReadyLabelField] = requestReadyLabel
if stamped, err := json.Marshal(item); err == nil {
return stamped
}
return raw
}
// invalidateHomeFor drops the cached Home payload for each named viewer, best-effort. Used
// wherever a request pin is created or retired outside a Home request itself — the ready
// sweep and the playback handler.
func (s *Server) invalidateHomeFor(ctx context.Context, userIDs ...string) {
if s.cache == nil {
return
}
for _, userID := range userIDs {
if userID == "" {
continue
}
if err := s.cache.InvalidateUser(ctx, userID); err != nil {
s.loggerFor(ctx).Warn("home cache not invalidated", "user_id", userID, "error", err)
}
}
}
@@ -0,0 +1,119 @@
package api
import (
"encoding/json"
"testing"
"github.com/ponzischeme89/memby/server/internal/store"
)
func readyReq(itemID, mediaType string) store.SurfacedReadyRequest {
return store.SurfacedReadyRequest{MediaType: mediaType, ItemID: itemID, Title: itemID}
}
func isStamped(t *testing.T, raw json.RawMessage) bool {
t.Helper()
var item struct {
Ready bool `json:"MembyRequestReady"`
Label string `json:"MembyRequestReadyLabel"`
}
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return item.Ready && item.Label == requestReadyLabel
}
// The overwhelmingly common case: nobody is waiting on anything, so the row is untouched.
func TestMergeRequestReadyNoArrivalsIsANoOp(t *testing.T) {
items := []json.RawMessage{resumeItem(t, "film", "", "2026-08-01T20:00:00Z")}
got := mergeRequestReady(items, nil, nil)
if !equalIDs(mergedIDs(t, got), []string{"film"}) {
t.Fatalf("ids = %v", mergedIDs(t, got))
}
if isStamped(t, got[0]) {
t.Fatal("item stamped with no arrivals")
}
}
// An arrival not in the row is prepended and tagged.
func TestMergeRequestReadyPrependsMissingTitle(t *testing.T) {
items := []json.RawMessage{resumeItem(t, "film", "", "2026-08-01T20:00:00Z")}
surfaced := []store.SurfacedReadyRequest{readyReq("new-movie", "movie")}
fetched := map[string]json.RawMessage{
"new-movie": continueRaw(t, map[string]any{"Id": "new-movie", "Type": "Movie"}),
}
got := mergeRequestReady(items, surfaced, fetched)
if want := []string{"new-movie", "film"}; !equalIDs(mergedIDs(t, got), want) {
t.Fatalf("ids = %v, want %v", mergedIDs(t, got), want)
}
if !isStamped(t, got[0]) {
t.Fatal("prepended card not stamped")
}
if isStamped(t, got[1]) {
t.Fatal("existing card wrongly stamped")
}
}
// Newest arrival first when several are prepended (surfaced is ordered newest first).
func TestMergeRequestReadyKeepsNewestArrivalFirst(t *testing.T) {
items := []json.RawMessage{resumeItem(t, "film", "", "2026-08-01T20:00:00Z")}
surfaced := []store.SurfacedReadyRequest{readyReq("newer", "movie"), readyReq("older", "movie")}
fetched := map[string]json.RawMessage{
"newer": continueRaw(t, map[string]any{"Id": "newer", "Type": "Movie"}),
"older": continueRaw(t, map[string]any{"Id": "older", "Type": "Movie"}),
}
got := mergedIDs(t, mergeRequestReady(items, surfaced, fetched))
if want := []string{"newer", "older", "film"}; !equalIDs(got, want) {
t.Fatalf("ids = %v, want %v", got, want)
}
}
// A surfaced title already in Continue Watching — by its own id, or as the series an
// episode belongs to — is tagged in place, never duplicated.
func TestMergeRequestReadyTagsExistingCardInPlace(t *testing.T) {
items := []json.RawMessage{
resumeItem(t, "s1e3", "severance", "2026-08-06T19:00:00Z"),
resumeItem(t, "the-film", "", "2026-08-01T20:00:00Z"),
}
surfaced := []store.SurfacedReadyRequest{
readyReq("severance", "series"),
readyReq("the-film", "movie"),
}
got := mergeRequestReady(items, surfaced, nil)
if want := []string{"s1e3", "the-film"}; !equalIDs(mergedIDs(t, got), want) {
t.Fatalf("ids = %v, want %v", mergedIDs(t, got), want)
}
if !isStamped(t, got[0]) || !isStamped(t, got[1]) {
t.Fatal("existing cards not tagged in place")
}
}
// A fetch that returned nothing for an arrival degrades to leaving it out rather than
// inserting a blank card.
func TestMergeRequestReadyDropsUnfetchableArrival(t *testing.T) {
items := []json.RawMessage{resumeItem(t, "film", "", "2026-08-01T20:00:00Z")}
surfaced := []store.SurfacedReadyRequest{readyReq("gone", "movie")}
got := mergeRequestReady(items, surfaced, map[string]json.RawMessage{})
if !equalIDs(mergedIDs(t, got), []string{"film"}) {
t.Fatalf("ids = %v", mergedIDs(t, got))
}
}
func TestStampRequestReadyPreservesOtherFields(t *testing.T) {
raw := continueRaw(t, map[string]any{"Id": "x", "Name": "Keep me", "Type": "Movie"})
stamped := stampRequestReady(raw)
var item struct {
Name string `json:"Name"`
Ready bool `json:"MembyRequestReady"`
}
if err := json.Unmarshal(stamped, &item); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if item.Name != "Keep me" || !item.Ready {
t.Fatalf("stamp lost fields: %+v", item)
}
}
+3 -1
View File
@@ -155,11 +155,13 @@ func (s *Server) handleTrailerReport(w http.ResponseWriter, r *http.Request, ses
writeError(w, http.StatusBadRequest, "invalid trailer report")
return
}
origin := s.resolveClientIP(r)
fields := []any{
"item_id", itemID,
"provider", report.Provider,
"candidate", report.CandidateID,
"source_ip", requestClientIP(r),
"source_ip", origin.String(),
"source_ip_via", origin.Via,
}
if reason := strings.TrimSpace(report.Reason); reason != "" {
fields = append(fields, "reason", reason)
+4
View File
@@ -66,6 +66,9 @@ func TestTrailerStartedLogUsesOriginalClientIP(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/items/film-1/trailers/report",
bytes.NewBufferString(`{"candidateId":"youtube-1","provider":"youtube","phase":"started"}`))
request.SetPathValue("id", "film-1")
// The request reaches the gateway through the household reverse proxy, whose LAN
// address is trusted by default, so its X-Forwarded-For is believed.
request.RemoteAddr = "10.0.0.2:44321"
request.Header.Set("X-Forwarded-For", "203.0.113.42, 10.0.0.2")
request, _ = withRequestIdentity(request)
identify(request.Context(), store.Session{Username: "viewer", DeviceName: "Lounge TV"})
@@ -76,6 +79,7 @@ func TestTrailerStartedLogUsesOriginalClientIP(t *testing.T) {
}
page := events.Events(0, 10)
if len(page.Events) != 1 || page.Events[0].Attributes["source_ip"] != "203.0.113.42" ||
page.Events[0].Attributes["source_ip_via"] != "forwarded" ||
page.Events[0].Message != "trailer playback started" {
t.Fatalf("unexpected event: %+v", page.Events)
}
+86 -16
View File
@@ -59,6 +59,53 @@ func previousMonth(now time.Time, location *time.Location) (from, to time.Time,
return from, to, from.Format("2006-01")
}
// priorWeekToDate is last week measured to the same point this week has reached: the local
// Monday a week ago, up to the same weekday and wall-clock time as now. AddDate keeps the
// wall clock across a daylight-saving change, so "the same time last week" stays the same
// time rather than drifting an hour — the reason weekStartIn works in dates too.
func priorWeekToDate(now time.Time, location *time.Location) (from, to time.Time) {
from = weekStartIn(now, location).AddDate(0, 0, -7)
to = now.In(location).AddDate(0, 0, -7)
return from, to
}
// watchWeekTrendFloor is how far this week's figure can sit from last week's before the
// difference is worth showing. The band is the larger of this and a tenth of last week —
// an absolute floor so a light household is not told "down" over a few minutes, and a
// proportion so a heavy one is not told "steady" over an hour.
const watchWeekTrendFloor = 5 * time.Minute
// watchWeekTrend is this week-to-date against the same point last week: a direction the
// console prints beside the figure, plus the numbers behind it. Direction is "up", "down",
// "steady" or "none" — the last when there is nothing to compare, the runtimestats
// DirectionUnknown stance.
type watchWeekTrend struct {
Direction string `json:"direction"`
PriorMs int64 `json:"priorMs"`
DeltaMs int64 `json:"deltaMs"`
}
// weekOverWeek classifies this week's watch time against last week's to-date figure. Pure,
// so the console and its tests agree on where "steady" ends.
func weekOverWeek(currentMs, priorMs int64) watchWeekTrend {
if currentMs <= 0 && priorMs <= 0 {
return watchWeekTrend{Direction: "none"}
}
delta := currentMs - priorMs
band := priorMs / 10
if floor := watchWeekTrendFloor.Milliseconds(); band < floor {
band = floor
}
direction := "steady"
switch {
case delta > band:
direction = "up"
case delta < -band:
direction = "down"
}
return watchWeekTrend{Direction: direction, PriorMs: priorMs, DeltaMs: delta}
}
// 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.
@@ -143,14 +190,18 @@ func monthlyDigestMessage(month time.Duration, monthName, topTitle string) strin
// 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"`
Matched bool `json:"matched"`
Username string `json:"tracearrUsername,omitempty"`
WeekMs int64 `json:"weekMs"`
// WeekTrend compares WeekMs with the same point last week, so the console can show
// whether a person's viewing is up or down without the operator holding last week's
// figure in their head.
WeekTrend watchWeekTrend `json:"weekTrend"`
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
@@ -220,16 +271,16 @@ type watchTimeAccount struct {
func (s *Server) watchTimeForAccounts(
ctx context.Context,
accounts []watchTimeAccount,
) map[string]store.WatchTimeTotals {
) (totals map[string]store.WatchTimeTotals, priorWeekMs map[string]int64) {
if !s.tracearrEnabled() || len(accounts) == 0 {
return nil
return nil, nil
}
location := s.householdLocation()
now := time.Now()
totals, err := s.store.TracearrWatchTime(ctx, weekStartIn(now, location), monthStartIn(now, location))
rows, 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
return nil, nil
}
identities, err := s.store.TracearrIdentities(ctx)
if err != nil {
@@ -238,18 +289,37 @@ func (s *Server) watchTimeForAccounts(
s.loggerFor(ctx).Warn("Tracearr identity map unavailable", "error", err)
identities = map[string]store.RecommendationIdentity{}
}
return attributeWatchTime(accounts, totals, identities)
attributed := attributeWatchTime(accounts, rows, identities)
// Last week to the same point, attributed the same two ways, so the console can say
// whether viewing is up or down. A failure here costs the arrow, never the figures.
priorWeek := map[string]int64{}
priorFrom, priorTo := priorWeekToDate(now, location)
windows, err := s.store.TracearrWatchTimeRange(ctx, priorFrom, priorTo)
if err != nil {
s.loggerFor(ctx).Warn("prior-week watch time read failed", "error", err)
} else {
byID, byName := indexWatchTimeRanges(windows)
for _, account := range accounts {
window := lookupWatchTimeRange(byID, byName, identities[account.ID], account.Username)
priorWeek[account.ID] = window.Ms
}
}
return attributed, priorWeek
}
// 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 {
// where there is no row. priorWeekMs is last week's viewing to the same point, for the
// week-over-week arrow — zero when there is nothing recorded, which weekOverWeek reads as
// "no change to show" rather than as a fall to nothing.
func summariseWatchTime(totals store.WatchTimeTotals, matched bool, priorWeekMs int64) watchTimeSummary {
if !matched {
return watchTimeSummary{}
}
return watchTimeSummary{
Matched: true, Username: totals.Username,
WeekMs: totals.WeekMs, MonthMs: totals.MonthMs, TotalMs: totals.TotalMs,
WeekMs: totals.WeekMs, WeekTrend: weekOverWeek(totals.WeekMs, priorWeekMs),
MonthMs: totals.MonthMs, TotalMs: totals.TotalMs,
WeekSessions: totals.WeekSessions, MonthSessions: totals.MonthSessions,
LastWatchedAt: totals.LastWatchedAt,
}
+42 -1
View File
@@ -185,6 +185,47 @@ func TestWatchTimeIsAttributedByRecordedIdentityBeforeName(t *testing.T) {
}
}
func TestPriorWeekToDateMatchesThePointThisWeekHasReached(t *testing.T) {
location := auckland(t)
// Wednesday 19 August 2026, mid-evening.
now := time.Date(2026, 8, 19, 21, 15, 0, 0, location)
from, to := priorWeekToDate(now, location)
if got, want := from.Format("2006-01-02 15:04"), "2026-08-10 00:00"; got != want {
t.Fatalf("prior week from = %s, want %s", got, want)
}
if got, want := to.Format("2006-01-02 15:04"), "2026-08-12 21:15"; got != want {
t.Fatalf("prior week to = %s, want %s (same weekday and time, a week back)", got, want)
}
}
func TestWeekOverWeek(t *testing.T) {
minute := int64(60 * 1000)
tests := []struct {
name string
current, prior int64
wantDir string
}{
{"nothing either week", 0, 0, "none"},
{"first watching this week", 90 * minute, 0, "up"},
{"stopped watching", 0, 90 * minute, "down"},
{"a few minutes more is still steady", 63 * minute, 60 * minute, "steady"},
{"well up on last week", 180 * minute, 60 * minute, "up"},
{"well down on last week", 20 * minute, 120 * minute, "down"},
{"identical", 45 * minute, 45 * minute, "steady"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := weekOverWeek(test.current, test.prior)
if got.Direction != test.wantDir {
t.Fatalf("weekOverWeek(%d, %d) = %q, want %q", test.current, test.prior, got.Direction, test.wantDir)
}
if got.Direction != "none" && got.DeltaMs != test.current-test.prior {
t.Fatalf("delta = %d, want %d", got.DeltaMs, test.current-test.prior)
}
})
}
}
func TestWatchTimeFallsBackToACaseInsensitiveName(t *testing.T) {
accounts := []watchTimeAccount{{ID: "emby-1", Username: "Matt"}}
totals := []store.WatchTimeTotals{{Username: "matt", WeekMs: 42}}
@@ -202,7 +243,7 @@ func TestAnUnmatchedAccountIsAbsentRatherThanZero(t *testing.T) {
if _, ok := got["emby-9"]; ok {
t.Fatal("an unmatched account was attributed watch time")
}
if summary := summariseWatchTime(store.WatchTimeTotals{}, false); summary.Matched {
if summary := summariseWatchTime(store.WatchTimeTotals{}, false, 0); summary.Matched {
t.Fatal("an unmatched summary claimed a match")
}
}
+72
View File
@@ -4,6 +4,7 @@ package config
import (
"encoding/json"
"fmt"
"net/netip"
"os"
"strconv"
"strings"
@@ -85,6 +86,17 @@ type Config struct {
UpstreamTimeout time.Duration
// TrustedProxies are the reverse proxies whose X-Forwarded-For and X-Real-IP
// headers the gateway will believe when working out where a request originated.
// A forwarded header is only honoured when the immediate peer is one of these, so
// a client reaching the gateway directly cannot spoof its address by setting one.
//
// Empty (MEMBY_TRUSTED_PROXIES unset) trusts loopback and the private/unique-local
// ranges — every reverse proxy a home deployment puts in front of Memby sits in one
// of those. Set it to a comma-separated list of addresses or CIDR ranges to narrow
// or widen that, or to "none" to trust no proxy at all.
TrustedProxies []netip.Prefix
// SlowRequestThreshold is how long a request has to take before its log line
// carries a stage breakdown. Fast requests deliberately carry none: it is the one
// field on the line that varies in width, and a column of them on every /v1/status
@@ -225,6 +237,10 @@ func Load() (Config, error) {
if err != nil {
return Config{}, err
}
trusted, err := trustedProxies(os.Getenv("MEMBY_TRUSTED_PROXIES"))
if err != nil {
return Config{}, fmt.Errorf("MEMBY_TRUSTED_PROXIES: %w", err)
}
c := Config{
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
@@ -261,6 +277,7 @@ func Load() (Config, error) {
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
TrustedProxies: trusted,
SlowRequestThreshold: duration("MEMBY_SLOW_REQUEST_THRESHOLD", 500*time.Millisecond),
EmbyHealthInterval: duration("MEMBY_EMBY_HEALTH_INTERVAL", 60*time.Second),
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
@@ -408,6 +425,61 @@ func duration(key string, fallback time.Duration) time.Duration {
return fallback
}
// DefaultTrustedProxyRanges is what MEMBY_TRUSTED_PROXIES falls back to: loopback and
// the private and unique-local ranges. It is exported so the request layer can use the
// same set when it is handed a nil list.
func DefaultTrustedProxyRanges() []netip.Prefix {
return parsePrefixes(
"127.0.0.0/8", "::1/128",
"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"169.254.0.0/16", "fe80::/10", "fc00::/7",
)
}
func parsePrefixes(values ...string) []netip.Prefix {
out := make([]netip.Prefix, 0, len(values))
for _, value := range values {
if prefix, err := netip.ParsePrefix(value); err == nil {
out = append(out, prefix.Masked())
}
}
return out
}
// trustedProxies reads the MEMBY_TRUSTED_PROXIES list. Blank means the defaults, the
// literal "none" means an empty (but non-nil) set, and every other token is an address
// or a CIDR range — with "private" as a shorthand for the default ranges.
func trustedProxies(raw string) ([]netip.Prefix, error) {
raw = strings.TrimSpace(raw)
switch {
case raw == "":
return DefaultTrustedProxyRanges(), nil
case strings.EqualFold(raw, "none"):
return []netip.Prefix{}, nil
}
out := []netip.Prefix{}
for _, token := range strings.Split(raw, ",") {
token = strings.TrimSpace(token)
if token == "" {
continue
}
if strings.EqualFold(token, "private") {
out = append(out, DefaultTrustedProxyRanges()...)
continue
}
if prefix, err := netip.ParsePrefix(token); err == nil {
out = append(out, prefix.Masked())
continue
}
addr, err := netip.ParseAddr(token)
if err != nil {
return nil, fmt.Errorf("%q is not an IP address or CIDR range", token)
}
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
}
return out, nil
}
func integer(key string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
+52
View File
@@ -1,6 +1,7 @@
package config
import (
"net/netip"
"os"
"path/filepath"
"testing"
@@ -89,6 +90,57 @@ func TestForYouRebuildHourIsValidated(t *testing.T) {
}
}
func TestTrustedProxiesDefaultToLoopbackAndPrivateRanges(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_TRUSTED_PROXIES", "")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
trusts := func(ip string) bool {
addr := netip.MustParseAddr(ip)
for _, prefix := range cfg.TrustedProxies {
if prefix.Contains(addr) {
return true
}
}
return false
}
for _, want := range []string{"127.0.0.1", "10.0.0.2", "192.168.1.5"} {
if !trusts(want) {
t.Fatalf("%s should be trusted by default", want)
}
}
if trusts("203.0.113.9") {
t.Fatal("a public address must not be trusted by default")
}
}
func TestTrustedProxiesNoneClearsTheList(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_TRUSTED_PROXIES", "none")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.TrustedProxies == nil || len(cfg.TrustedProxies) != 0 {
t.Fatalf("trusted proxies = %v, want an empty non-nil list", cfg.TrustedProxies)
}
}
func TestTrustedProxiesRejectGarbage(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_TRUSTED_PROXIES", "10.0.0.0/8, not-an-address")
if _, err := Load(); err == nil {
t.Fatal("expected an unparseable trusted proxy entry to fail")
}
}
func TestRecommendationWeightsMustBeJSON(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
+24 -10
View File
@@ -82,18 +82,29 @@ type RemotePageConfig struct {
// RemoteSectionDefinition is the stable composition contract. Clients render only
// known component types and ignore definitions introduced by newer gateways.
type RemoteSectionDefinition struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Enabled bool `json:"enabled"`
Position int `json:"position"`
DataSource string `json:"dataSource"`
Component string `json:"component"`
MaxItems int `json:"maxItems,omitempty"`
Destination string `json:"destination,omitempty"`
Settings map[string]any `json:"settings,omitempty"`
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Enabled bool `json:"enabled"`
Position int `json:"position"`
DataSource string `json:"dataSource"`
Component string `json:"component"`
MaxItems int `json:"maxItems,omitempty"`
Destination string `json:"destination,omitempty"`
// Layout overrides the card shape a mediaRow draws: "poster" forces upright poster
// cards, "thumb" forces the wide landscape cards Continue Watching uses. Empty leaves
// the client's automatic choice (episodes landscape, everything else poster) alone.
Layout string `json:"layout,omitempty"`
Settings map[string]any `json:"settings,omitempty"`
}
// SectionLayoutPoster and SectionLayoutThumb are the two explicit card shapes an operator
// can pin a row to; anything else means "let the client decide".
const (
SectionLayoutPoster = "poster"
SectionLayoutThumb = "thumb"
)
type RemoteContinueWatching struct {
Enabled bool `json:"enabled"`
IncludeNextUp bool `json:"includeNextUp"`
@@ -321,6 +332,9 @@ func validateRemoteConfigSections(document RemoteConfig) error {
if section.Position < 0 || section.MaxItems < 0 || section.MaxItems > 500 {
return fmt.Errorf("remote configuration section has an invalid position or item limit")
}
if section.Layout != "" && section.Layout != SectionLayoutPoster && section.Layout != SectionLayoutThumb {
return fmt.Errorf("remote configuration section %q has an invalid layout", section.ID)
}
}
}
return nil
+4
View File
@@ -23,6 +23,10 @@ type Row struct {
Title string `json:"title"`
Kind string `json:"kind"`
Items []json.RawMessage `json:"items"`
// Layout is an operator-pinned card shape ("poster" or "thumb") from the section
// definitions, or empty to leave the client's automatic choice alone. It is stamped
// onto the finished rows by applyRowLayoutOverrides.
Layout string `json:"layout,omitempty"`
}
// Source is the slice of the Emby client this package needs, narrowed so tests can
+22
View File
@@ -66,6 +66,18 @@ type GatewaySettings struct {
// it is still the only way anything is discovered and must stay frequent.
LibrarySyncMinutes int `json:"librarySyncMinutes"`
// HomeTTLSeconds and RecommendTTLHours are how long the launcher payload and the
// personalised recommendation pool stay cached. They cannot be switched off — a TTL of
// nothing means every home load rebuilds — so zero keeps meaning "deployed" and any
// positive value is the override.
HomeTTLSeconds int `json:"homeTtlSeconds"`
RecommendTTLHours int `json:"recommendTtlHours"`
// ForYouRebuildHour is the household-local hour (023) the daily For You rebuild is due
// at. It is a pointer because 0 is a legitimate hour, so nil — not zero — is what means
// "whatever was deployed".
ForYouRebuildHour *int `json:"forYouRebuildHour,omitempty"`
UpdatedAt time.Time `json:"updatedAt"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
@@ -125,6 +137,16 @@ func normalizeGatewaySettings(settings GatewaySettings) GatewaySettings {
// A day is the ceiling rather than a week: however well the webhooks are working, the
// sweep is the only thing that ever notices a file somebody moved by hand.
settings.LibrarySyncMinutes = clampOverride(settings.LibrarySyncMinutes, 5, 24*60, true)
settings.HomeTTLSeconds = clampOverride(settings.HomeTTLSeconds, 5, 3600, false)
settings.RecommendTTLHours = clampOverride(settings.RecommendTTLHours, 1, 168, false)
if settings.ForYouRebuildHour != nil {
hour := *settings.ForYouRebuildHour
if hour < 0 || hour > 23 {
// An hour outside the clock is dropped rather than clamped: 25 is a typo, and
// silently reading it as 23 would run the rebuild at a time nobody asked for.
settings.ForYouRebuildHour = nil
}
}
return settings
}
@@ -2,6 +2,31 @@ package store
import "testing"
func TestNormalizeGatewaySettingsCacheAndRebuildOverrides(t *testing.T) {
hour := 25
settings := normalizeGatewaySettings(GatewaySettings{
HomeTTLSeconds: 2, RecommendTTLHours: 500, ForYouRebuildHour: &hour,
})
// TTLs cannot be switched off, so a sub-floor value clamps up and an over-ceiling one
// clamps down rather than either reading as "deployed".
if settings.HomeTTLSeconds != 5 {
t.Fatalf("home TTL should clamp to the floor, got %d", settings.HomeTTLSeconds)
}
if settings.RecommendTTLHours != 168 {
t.Fatalf("recommend TTL should clamp to the ceiling, got %d", settings.RecommendTTLHours)
}
// An hour outside the clock is a typo, and is dropped rather than clamped.
if settings.ForYouRebuildHour != nil {
t.Fatalf("an out-of-range rebuild hour should be dropped, got %d", *settings.ForYouRebuildHour)
}
valid := 0
kept := normalizeGatewaySettings(GatewaySettings{ForYouRebuildHour: &valid})
if kept.ForYouRebuildHour == nil || *kept.ForYouRebuildHour != 0 {
t.Fatalf("midnight is a legitimate rebuild hour and must survive, got %v", kept.ForYouRebuildHour)
}
}
// Normalisation is what stands between a hand-edited row (or a console built against an
// older vocabulary) and a gateway that cannot decide what day it is.
func TestNormalizeGatewaySettingsRefusesWhatItCannotUse(t *testing.T) {
+141
View File
@@ -180,6 +180,147 @@ func (s *Store) AllMediaRequests(ctx context.Context, limit int) ([]OwnedMediaRe
// MediaRequestSweepLimit caps what one pass of the ready sweep will look at.
const MediaRequestSweepLimit = 500
// RequestReadySurfaceWindow is how long a freshly arrived request stays pinned to the front
// of Continue Watching with a REQUEST READY tag if the viewer never plays it. Past this the
// age-out clears it: an arrival nobody has acted on in a fortnight is no longer news, and a
// pin that outstays its welcome is worse than one that lapses.
const RequestReadySurfaceWindow = 14 * 24 * time.Hour
// SurfacedReadyRequest is one arrival still worth pinning: which title, and the Emby item id
// the card opens and dedupes against.
type SurfacedReadyRequest struct {
MediaType string
ForeignID int
Title string
Year int
PosterURL string
ItemID string
SurfacedAt time.Time
}
// MarkRequestReadySurfaced records that a request has become watchable and should be pinned.
//
// The guard is the whole of the idempotency: the ready sweep calls this on every observed
// transition to "available", and only the first one — before anything has cleared it — takes
// effect. A request cleared by playback or the age-out is never re-pinned here; a genuinely
// new ask is a fresh row with both timestamps null.
func (s *Store) MarkRequestReadySurfaced(
ctx context.Context, userID, mediaType string, foreignID int, itemID string,
) error {
itemID = strings.TrimSpace(itemID)
if itemID == "" {
return fmt.Errorf("store: request ready needs an item id")
}
_, err := s.pool.Exec(ctx, `
UPDATE media_requests
SET ready_surfaced_at = now(), ready_item_id = $4
WHERE emby_user_id = $1 AND media_type = $2 AND foreign_id = $3
AND ready_surfaced_at IS NULL AND ready_cleared_at IS NULL`,
strings.TrimSpace(userID), mediaType, foreignID, itemID)
if err != nil {
return fmt.Errorf("store: mark request ready surfaced: %w", err)
}
return nil
}
// SurfacedReadyRequests reads one viewer's still-pinned arrivals, newest first.
//
// The window and the cleared check are both in the query so the caller never has to think
// about either: a row past RequestReadySurfaceWindow, or one playback has cleared, simply
// does not come back. This is the read Home makes beside its Emby fan-out.
func (s *Store) SurfacedReadyRequests(
ctx context.Context, userID string,
) ([]SurfacedReadyRequest, error) {
cutoff := time.Now().Add(-RequestReadySurfaceWindow)
rows, err := s.pool.Query(ctx, `
SELECT media_type, foreign_id, title, year, poster_url, ready_item_id, ready_surfaced_at
FROM media_requests
WHERE emby_user_id = $1
AND ready_surfaced_at IS NOT NULL
AND ready_cleared_at IS NULL
AND ready_surfaced_at > $2
AND ready_item_id <> ''
ORDER BY ready_surfaced_at DESC`,
strings.TrimSpace(userID), cutoff)
if err != nil {
return nil, fmt.Errorf("store: read surfaced ready requests: %w", err)
}
defer rows.Close()
out := []SurfacedReadyRequest{}
for rows.Next() {
var req SurfacedReadyRequest
if err := rows.Scan(
&req.MediaType, &req.ForeignID, &req.Title, &req.Year,
&req.PosterURL, &req.ItemID, &req.SurfacedAt,
); err != nil {
return nil, fmt.Errorf("store: scan surfaced ready request: %w", err)
}
out = append(out, req)
}
return out, rows.Err()
}
// ClearRequestReadySurfaced retires the pin for whichever of a viewer's requests point at
// the given Emby item id — the film itself, or the series an episode belongs to. It returns
// how many rows it touched so the caller can skip a cache invalidation that would change
// nothing.
func (s *Store) ClearRequestReadySurfaced(
ctx context.Context, userID string, itemIDs ...string,
) (int64, error) {
ids := make([]string, 0, len(itemIDs))
for _, id := range itemIDs {
if id = strings.TrimSpace(id); id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return 0, nil
}
tag, err := s.pool.Exec(ctx, `
UPDATE media_requests
SET ready_cleared_at = now()
WHERE emby_user_id = $1 AND ready_item_id = ANY($2)
AND ready_surfaced_at IS NOT NULL AND ready_cleared_at IS NULL`,
strings.TrimSpace(userID), ids)
if err != nil {
return 0, fmt.Errorf("store: clear request ready surfaced: %w", err)
}
return tag.RowsAffected(), nil
}
// ExpireStaleReadyRequests clears every pin past RequestReadySurfaceWindow across the whole
// household in one statement, and returns the users whose Home cache is now stale. The ready
// sweep runs this so SurfacedReadyRequests never has to lean on its own window clause to
// hide a row that should have been retired days ago.
func (s *Store) ExpireStaleReadyRequests(ctx context.Context) ([]string, error) {
cutoff := time.Now().Add(-RequestReadySurfaceWindow)
rows, err := s.pool.Query(ctx, `
UPDATE media_requests
SET ready_cleared_at = now()
WHERE ready_surfaced_at IS NOT NULL AND ready_cleared_at IS NULL
AND ready_surfaced_at <= $1
RETURNING emby_user_id`, cutoff)
if err != nil {
return nil, fmt.Errorf("store: expire stale ready requests: %w", err)
}
defer rows.Close()
seen := map[string]bool{}
users := []string{}
for rows.Next() {
var userID string
if err := rows.Scan(&userID); err != nil {
return nil, err
}
if !seen[userID] {
seen[userID] = true
users = append(users, userID)
}
}
return users, rows.Err()
}
// SetMediaRequestStatus records what a request was last seen doing.
//
// Written only when the state actually moved, so a sweep over a household where nothing has
+16
View File
@@ -615,6 +615,22 @@ ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS last_status TEXT NOT NULL DE
CREATE INDEX IF NOT EXISTS media_requests_user_requested_idx
ON media_requests (emby_user_id, requested_at DESC);
-- The arrival is announced once as a notification, and then pinned to the front of that
-- viewer's Continue Watching row with a REQUEST READY tag until they start watching it.
-- These three columns are the memory of that pin: ready_surfaced_at is when the sweep first
-- saw the title become watchable (and only then, when the library also had an item id to
-- open); ready_item_id is what the row pins and dedupes against; ready_cleared_at is set the
-- moment playback starts or the 14-day age-out fires, and a non-null value stops the request
-- ever being surfaced again — asking afresh after a delete is a new row, which is the case
-- where the pin is genuinely new.
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS ready_surfaced_at TIMESTAMPTZ;
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS ready_item_id TEXT NOT NULL DEFAULT '';
ALTER TABLE media_requests ADD COLUMN IF NOT EXISTS ready_cleared_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS media_requests_user_surfaced_idx
ON media_requests (emby_user_id)
WHERE ready_surfaced_at IS NOT NULL AND ready_cleared_at IS NULL;
-- Every sign-in attempt, successful or not.
--
-- The sessions table above holds one row per television and is overwritten by the next