0.3.49
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (0–23) 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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user