Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+196 -15
View File
@@ -6,6 +6,9 @@ import (
_ "embed"
"encoding/json"
"net/http"
"os"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"
@@ -26,19 +29,68 @@ const adminCookieName = "memby_admin"
func (s *Server) adminRoutes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /admin/{$}", s.handleAdminPage)
mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
mux.HandleFunc("GET /admin/{page}", s.handleAdminPage)
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
mux.Handle("POST /admin/api/features", s.adminAuth(s.handleAdminFeaturePolicy))
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
return mux
}
var adminPages = map[string]bool{
"library": true, "recommendations": true, "requests": true,
"features": true, "playback": true, "maintenance": true, "updates": true, "engagement": true,
"imports": true, "logs": true,
}
func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/admin/features", http.StatusFound)
}
type adminRuntimeStatus struct {
Goroutines int `json:"goroutines"`
GOMAXPROCS int `json:"gomaxprocs"`
HeapAlloc uint64 `json:"heapAlloc"`
HeapInuse uint64 `json:"heapInuse"`
HeapIdle uint64 `json:"heapIdle"`
HeapReleased uint64 `json:"heapReleased"`
StackInuse uint64 `json:"stackInuse"`
Sys uint64 `json:"sys"`
NextGC uint64 `json:"nextGc"`
NumGC uint32 `json:"numGc"`
MemoryLimit int64 `json:"memoryLimit"`
ConfiguredLim string `json:"configuredLimit,omitempty"`
}
func (s *Server) handleAdminRuntime(w http.ResponseWriter, _ *http.Request) {
var memory runtime.MemStats
runtime.ReadMemStats(&memory)
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, adminRuntimeStatus{
Goroutines: runtime.NumGoroutine(), GOMAXPROCS: runtime.GOMAXPROCS(0),
HeapAlloc: memory.HeapAlloc, HeapInuse: memory.HeapInuse,
HeapIdle: memory.HeapIdle, HeapReleased: memory.HeapReleased,
StackInuse: memory.StackInuse, Sys: memory.Sys,
NextGC: memory.NextGC, NumGC: memory.NumGC,
MemoryLimit: debug.SetMemoryLimit(-1), ConfiguredLim: os.Getenv("GOMEMLIMIT"),
})
}
func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) {
if s.events == nil {
writeJSON(w, http.StatusOK, map[string]any{
@@ -52,22 +104,25 @@ func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.events.Events(after, limit))
}
// adminAuth guards the admin API with the shared token. Browser requests use the
// persistent HttpOnly cookie established by the admin page; automation can continue to
// send the token as a Bearer header.
// adminAuth guards the admin API with the shared token. Browser requests need both the
// admin cookie and a current Emby-verified browser session. Automation can continue to
// send the admin token as a Bearer header without pretending to be a browser.
func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if presented == "" {
authorization := strings.TrimSpace(r.Header.Get("Authorization"))
presented := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
browser := presented == ""
if browser {
if cookie, err := r.Cookie(adminCookieName); err == nil {
presented = cookie.Value
}
}
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 {
if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 ||
(browser && !s.validInstallerSession(r)) {
writeError(w, http.StatusUnauthorized, "invalid admin token")
return
}
@@ -80,6 +135,15 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
page := strings.TrimSpace(r.PathValue("page"))
if !adminPages[page] {
http.NotFound(w, r)
return
}
if !s.validInstallerSession(r) {
s.renderAccessLogin(w, r, "", http.StatusOK, "/admin/"+page)
return
}
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
@@ -92,18 +156,26 @@ func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
preventDiscovery(w)
_, _ = w.Write(adminPage)
}
type adminStatus struct {
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"`
Runs []store.SyncRun `json:"runs"`
SyncEvery string `json:"syncEvery"`
ForYou store.ForYouStats `json:"forYou"`
ForYouRunning bool `json:"forYouRunning"`
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
SyncRunning bool `json:"syncRunning"`
Runs []store.SyncRun `json:"runs"`
SyncEvery string `json:"syncEvery"`
ForYou store.ForYouStats `json:"forYou"`
ForYouRunning bool `json:"forYouRunning"`
RequestPolicy store.RequestPolicy `json:"requestPolicy"`
PlaybackPolicy store.PlaybackPolicy `json:"playbackPolicy"`
Features featureResponse `json:"features"`
RequestUsers []store.KnownUser `json:"requestUsers"`
Clients []store.KnownClient `json:"clients"`
SonarrReady bool `json:"sonarrReady"`
RadarrReady bool `json:"radarrReady"`
}
func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
@@ -131,6 +203,18 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
}
forYouRunning = s.forYou.Running()
}
requestPolicy, err := s.store.RequestPolicy(ctx)
if err != nil {
s.log.Warn("request policy read failed", "error", err)
}
requestUsers, err := s.store.KnownUsers(ctx)
if err != nil {
s.log.Warn("known users read failed", "error", err)
}
clients, err := s.store.KnownClients(ctx)
if err != nil {
s.log.Warn("known clients read failed", "error", err)
}
writeJSON(w, http.StatusOK, adminStatus{
Maintenance: s.maintenance.get(),
UpdatePolicy: s.updatePolicy.get(),
@@ -140,9 +224,99 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
SyncEvery: s.cfg.SyncInterval.String(),
ForYou: forYouStats,
ForYouRunning: forYouRunning,
RequestPolicy: requestPolicy,
PlaybackPolicy: func() store.PlaybackPolicy {
policy, policyErr := s.store.PlaybackPolicy(ctx)
if policyErr != nil {
s.log.Warn("playback policy read failed", "error", policyErr)
return store.DefaultPlaybackPolicy()
}
return policy
}(),
Features: featurePayload(s.currentFeaturePolicy(ctx), membyProtocolVersion),
RequestUsers: requestUsers,
Clients: clients,
SonarrReady: s.sonarr != nil,
RadarrReady: s.radarr != nil,
})
}
type playbackPolicyRequest struct {
PrerollEnabled bool `json:"prerollEnabled"`
PrerollDurationMs int64 `json:"prerollDurationMs"`
}
func (s *Server) handleAdminPlaybackPolicy(w http.ResponseWriter, r *http.Request) {
var req playbackPolicyRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if req.PrerollDurationMs < 1_000 || req.PrerollDurationMs > 30_000 {
writeError(w, http.StatusBadRequest, "preroll duration must be between 1 and 30 seconds")
return
}
policy := store.PlaybackPolicy{
PrerollEnabled: req.PrerollEnabled, PrerollDurationMs: req.PrerollDurationMs,
}
if err := s.store.SetPlaybackPolicy(r.Context(), policy); err != nil {
s.log.Error("playback policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save playback policy")
return
}
stored, err := s.store.PlaybackPolicy(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not reload playback policy")
return
}
s.log.Info("playback policy changed", "preroll_enabled", stored.PrerollEnabled,
"preroll_duration_ms", stored.PrerollDurationMs)
writeJSON(w, http.StatusOK, stored)
}
type requestPolicyRequest struct {
AllowedUserIDs []string `json:"allowedUserIds"`
}
func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request) {
var req requestPolicyRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
known, err := s.store.KnownUsers(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not validate users")
return
}
valid := make(map[string]bool, len(known))
for _, user := range known {
valid[user.ID] = true
}
seen := map[string]bool{}
allowed := make([]string, 0, len(req.AllowedUserIDs))
for _, id := range req.AllowedUserIDs {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
if !valid[id] {
writeError(w, http.StatusBadRequest, "unknown Emby user")
return
}
seen[id] = true
allowed = append(allowed, id)
}
policy := store.RequestPolicy{AllowedUserIDs: allowed}
if err := s.store.SetRequestPolicy(r.Context(), policy); err != nil {
s.log.Error("request policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save request access")
return
}
s.log.Info("media request access changed", "users", len(allowed))
writeJSON(w, http.StatusOK, policy)
}
type updatePolicyRequest struct {
Enabled bool `json:"enabled"`
LatestVersion string `json:"latestVersion"`
@@ -171,6 +345,13 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
DownloadURL: strings.TrimSpace(req.DownloadURL),
Notes: strings.TrimSpace(req.Notes),
}
current := s.updatePolicy.get()
if policy.LatestVersion == current.LatestVersion && policy.DownloadURL == current.DownloadURL {
// Changing "required" or release notes must not silently discard integrity
// metadata added by the signed release publisher.
policy.SHA256 = current.SHA256
policy.SizeBytes = current.SizeBytes
}
if req.Required {
// Forcing means "nobody below the current build", so the floor is the latest.
policy.MinimumVersion = policy.LatestVersion
+584 -23
View File
@@ -7,19 +7,75 @@
<style>
:root {
color-scheme: dark;
--bg: #0b0e11; --panel: #151a20; --line: #232a32;
--text: #e6eaee; --muted: #97a1ab; --accent: #52b54b; --danger: #e5534b;
--bg: #080b0e; --panel: #151a20; --panel-raised: #1a2027; --line: #252d35;
--text: #edf1f4; --muted: #98a3ad; --accent: #52b54b; --accent-soft: #18351b;
--danger: #e5534b; --rail-width: 242px;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { scroll-behavior: smooth; scroll-padding-top: 28px; }
body {
margin: 0; padding: 28px; background: var(--bg); color: var(--text);
margin: 0; background:
radial-gradient(circle at 75% -20%, rgba(82,181,75,.09), transparent 34rem),
var(--bg); color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { width: 100%; margin: 0; display: grid; gap: 20px; }
h1 { font-size: 24px; margin: 0; }
.side-rail {
position: fixed; inset: 0 auto 0 0; z-index: 20; width: var(--rail-width);
display: flex; flex-direction: column; padding: 22px 12px 16px;
background: rgba(14,18,22,.97); border-right: 1px solid var(--line);
box-shadow: 12px 0 36px rgba(0,0,0,.18); overflow-y: auto;
}
.brand {
display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 0 10px;
color: var(--text); text-decoration: none;
}
.brand-mark {
display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 34px;
border-radius: 11px; background: var(--accent); color: #071608;
font-size: 19px; font-weight: 900; letter-spacing: -.08em;
box-shadow: 0 5px 18px rgba(82,181,75,.25);
}
.brand-copy { min-width: 0; }
.brand-copy b { display: block; font-size: 17px; line-height: 1.1; }
.brand-copy span { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; letter-spacing: .04em; }
.rail-label {
margin: 24px 12px 8px; color: #69747e; font-size: 10px; font-weight: 700;
letter-spacing: .13em; text-transform: uppercase;
}
.rail-nav { display: grid; gap: 4px; }
.rail-link {
position: relative; display: flex; align-items: center; gap: 13px; min-height: 44px;
padding: 0 12px; border-radius: 9px; color: #aeb7bf; text-decoration: none;
font-size: 13px; font-weight: 600; transition: color .14s, background .14s, transform .14s;
}
.rail-link svg {
width: 19px; height: 19px; flex: 0 0 19px; fill: none; stroke: currentColor;
stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round;
}
.rail-link:hover { color: var(--text); background: #171d23; transform: translateX(2px); }
.rail-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.rail-link.active { color: #fff; background: var(--accent-soft); }
.rail-link.active::before {
content: ""; position: absolute; left: -12px; width: 3px; height: 24px;
border-radius: 0 4px 4px 0; background: var(--accent);
}
.rail-footer { margin-top: auto; padding: 15px 10px 0; border-top: 1px solid var(--line); }
.rail-footer .pill { display: inline-block; }
main {
width: calc(100% - var(--rail-width)); margin-left: var(--rail-width);
padding: 28px clamp(22px, 3vw, 48px) 60px; display: grid; gap: 20px;
}
h1 { font-size: clamp(24px, 3vw, 32px); line-height: 1.15; margin: 0; letter-spacing: -.025em; }
h2 { font-size: 15px; margin: 0 0 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
header { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
section { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px; }
header { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; flex-wrap: wrap; padding: 6px 2px 10px; }
.eyebrow { margin: 0 0 5px; color: var(--accent); font-size: 11px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
.page-intro { margin: 7px 0 0; color: var(--muted); }
section {
scroll-margin-top: 24px; background: linear-gradient(145deg, var(--panel-raised), var(--panel));
border: 1px solid var(--line); border-radius: 13px; padding: 20px;
box-shadow: 0 12px 34px rgba(0,0,0,.12);
}
button {
background: var(--accent); color: #06240a; border: 0; border-radius: 7px;
padding: 9px 15px; font-weight: 600; font-size: 14px; cursor: pointer;
@@ -27,7 +83,7 @@
button.secondary { background: #2a323b; color: var(--text); }
button.danger { background: var(--danger); color: #fff; }
button:disabled { opacity: .45; cursor: not-allowed; }
input[type=text], input[type=password] {
input[type=text], input[type=password], input[type=number], input[type=datetime-local] {
background: #0e1216; border: 1px solid var(--line); border-radius: 7px;
color: var(--text); padding: 9px 11px; font-size: 14px; min-width: 260px;
}
@@ -68,23 +124,133 @@
.event-level.WARN { color:#f0c674; }
.event-level.DEBUG { color:#88a4bd; }
.event-empty { padding:20px; color:var(--muted); }
.recommendation-controls { display:grid; grid-template-columns:repeat(4,minmax(150px,1fr)) auto; gap:10px; align-items:end; margin:14px 0; }
.recommendation-controls label { display:grid; gap:5px; color:var(--muted); font-size:12px; }
.recommendation-controls input { min-width:0; width:100%; }
.recommendation-list { display:grid; gap:10px; }
.recommendation-card { border:1px solid var(--line); border-radius:10px; background:#10151a; padding:14px; }
.recommendation-head { display:flex; gap:14px; justify-content:space-between; align-items:flex-start; }
.recommendation-title { font-size:16px; font-weight:700; }
.recommendation-score { color:#8ee39a; font:700 20px ui-monospace,Consolas,monospace; }
.reason-code,.component { display:inline-block; margin:5px 5px 0 0; padding:3px 7px; border-radius:5px; font-size:11px; }
.reason-code { color:#a9d7ff; background:#152a3b; }
.component { color:#d6dde3; background:#252d35; font-family:ui-monospace,Consolas,monospace; }
.component.negative { color:#ffaaa3; background:#3a1d1c; }
.recommendation-meta { color:var(--muted); font-size:12px; margin-top:4px; }
.reason-text { margin-top:9px; color:#dce3e8; }
.feature-hero {
display:grid; grid-template-columns:minmax(0,1fr) auto; gap:22px; align-items:center;
padding:20px; margin-bottom:16px; border:1px solid #2b4830; border-radius:11px;
background:linear-gradient(120deg,rgba(82,181,75,.15),rgba(16,21,26,.7));
}
.feature-hero.safe { border-color:#705d2b; background:linear-gradient(120deg,rgba(240,198,116,.13),rgba(16,21,26,.7)); }
.feature-health { display:flex; gap:24px; flex-wrap:wrap; margin-top:13px; }
.feature-health b { display:block; font-size:20px; }
.feature-health span { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
.feature-grid { display:grid; grid-template-columns:repeat(2,minmax(260px,1fr)); gap:12px; margin:16px 0; }
.feature-card { display:grid; gap:11px; padding:16px; border:1px solid var(--line); border-radius:10px; background:#10151a; }
.feature-card-head { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
.feature-card h3 { margin:0; font-size:16px; }
.feature-card p { margin:3px 0 0; color:var(--muted); font-size:13px; }
.feature-meta { display:flex; gap:7px; flex-wrap:wrap; align-items:center; }
.feature-meta code { color:#b7c1c9; font-size:11px; }
.feature-select { min-width:128px; font-weight:650; }
.recovery-note { color:#86ad8b; font-size:12px; }
.feature-actions { display:flex; gap:9px; align-items:center; flex-wrap:wrap; padding-top:15px; border-top:1px solid var(--line); }
details { margin-top:9px; }
summary { color:var(--muted); cursor:pointer; }
button:focus-visible, input:focus-visible, select:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
@media (max-width: 900px) {
body { padding:14px; }
:root { --rail-width: 72px; }
.side-rail { padding: 16px 8px; align-items: stretch; }
.brand { justify-content: center; padding: 0; }
.brand-copy, .rail-label, .rail-link span, .rail-footer span { display: none; }
.rail-nav { margin-top: 22px; gap: 6px; }
.rail-link { justify-content: center; padding: 0; min-height: 45px; }
.rail-link.active::before { left: -8px; }
.rail-footer { padding: 14px 0 0; text-align: center; }
main { padding: 20px 14px 44px; }
.event-line { grid-template-columns:150px 55px 1fr; }
.event-attrs { grid-column:1 / -1; }
.recommendation-controls { grid-template-columns:repeat(2,minmax(150px,1fr)); }
.feature-grid { grid-template-columns:1fr; }
}
@media (max-width: 560px) {
input[type=text], input[type=password], input[type=number], input[type=datetime-local],
.event-toolbar input[type=search] { min-width: 100% !important; width: 100%; }
section { padding: 16px; }
.stats { gap: 18px; }
.recommendation-controls { grid-template-columns:1fr; }
.feature-hero { grid-template-columns:1fr; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.rail-link { transition: none; }
}
</style>
</head>
<body>
<aside class="side-rail" aria-label="Admin navigation">
<a class="brand" href="/admin/features" aria-label="Memby admin home">
<span class="brand-mark" aria-hidden="true">M</span>
<span class="brand-copy"><b>Memby</b><span>ADMIN CONSOLE</span></span>
</a>
<div class="rail-label">Manage</div>
<nav class="rail-nav">
<a class="rail-link" href="/admin/features" data-section="features" title="Features">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6"/></svg><span>Features</span>
</a>
<a class="rail-link" href="/admin/library" data-section="library" title="Library">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5h16v13H4zM8 5.5v13M4 10h4"/></svg><span>Library</span>
</a>
<a class="rail-link" href="/admin/recommendations" data-section="recommendations" title="Recommendations">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 1.5 5 5 .2-4 3 1.4 5-3.9-2.8-3.9 2.8 1.4-5-4-3 5-.2L12 3Z"/></svg><span>Recommendations</span>
</a>
<a class="rail-link" href="/admin/requests" data-section="requests" title="Media requests">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4h14v16H5zM8 8h8M8 12h5M15 16h3m-1.5-1.5v3"/></svg><span>Media requests</span>
</a>
<a class="rail-link" href="/admin/playback" data-section="playback" title="Playback">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7zM4 5v14"/></svg><span>Playback</span>
</a>
<a class="rail-link" href="/admin/maintenance" data-section="maintenance" title="Maintenance">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14.5 6.5 3-3 3 3-3 3M9 15l-5.5 5.5M13 4a5 5 0 0 0 6.5 6.5L10 20l-6-6 9.5-9.5Z"/></svg><span>Maintenance</span>
</a>
<a class="rail-link" href="/admin/updates" data-section="updates" title="App updates">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 16V4m0 0L8 8m4-4 4 4M5 13v6h14v-6"/></svg><span>App updates</span>
</a>
</nav>
<div class="rail-label">Observe</div>
<nav class="rail-nav">
<a class="rail-link" href="/admin/engagement" data-section="engagement" title="Row engagement">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V9m5 10V5m5 14v-7m5 7V3"/></svg><span>Row engagement</span>
</a>
<a class="rail-link" href="/admin/imports" data-section="imports" title="Recent imports">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5"/></svg><span>Recent imports</span>
</a>
<a class="rail-link" href="/admin/logs" data-section="logs" title="Server logs">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v14H4zM7 9l2 2-2 2m5 1h5"/></svg><span>Server logs</span>
</a>
</nav>
<div class="rail-footer">
<span class="muted">Server status</span>
<div id="rail-live" class="pill muted" style="margin-top:7px">connecting…</div>
</div>
</aside>
<main>
<header>
<h1>Memby admin</h1>
<header id="top">
<div>
<p class="eyebrow">Memby gateway</p>
<h1 id="page-title">Admin console</h1>
<p class="page-intro" id="page-intro">Manage the library, TV experience and server health.</p>
</div>
<span id="live" class="pill muted">connecting…</span>
</header>
<div id="error" class="banner"></div>
<section>
<section id="library" data-admin-page="library">
<h2>Library</h2>
<div class="stats" id="library-stats"><span class="muted">Loading…</span></div>
<div class="row">
@@ -94,7 +260,7 @@
</div>
</section>
<section>
<section id="for-you" data-admin-page="recommendations">
<h2>For You</h2>
<div class="stats" id="for-you-stats"><span class="muted">Loading…</span></div>
<div class="row">
@@ -103,9 +269,45 @@
<button id="for-you-rebuild" class="secondary">Rebuild all pools</button>
<span class="muted" id="for-you-hint"></span>
</div>
<hr style="border:0;border-top:1px solid var(--line);margin:20px 0">
<h2 style="margin-bottom:4px">Per-user pressure test</h2>
<p class="muted" style="margin-top:0">
Re-runs the shared weighted scorer over the user's prepared pool, after Emby
permission and parental-control filtering. Inspect every component and evidence reason.
</p>
<div class="recommendation-controls">
<label>User<select id="recommendation-user"><option value="">Choose a user…</option></select></label>
<label>Context<select id="recommendation-context">
<option value="default">Default</option>
<option value="bedtime">One episode before bed</option>
<option value="hidden">Hidden library</option>
<option value="new-releases">Recent new releases</option>
</select></label>
<label>Available minutes<input id="recommendation-minutes" type="number" min="0" max="360" value="0"></label>
<label>Evaluate at<input id="recommendation-at" type="datetime-local"></label>
<button id="recommendation-load">Run pressure test</button>
</div>
<div id="recommendation-summary" class="stats"></div>
<div id="recommendation-profile"></div>
<div id="recommendation-results" class="recommendation-list">
<span class="muted">Choose a user to inspect their recommendations.</span>
</div>
</section>
<section>
<section id="requests" data-admin-page="requests">
<h2>Media requests</h2>
<p class="muted" style="margin-top:0">
Choose who sees “Request it” when a library search has no matches. Movies are added
to Radarr and shows to Sonarr as unmonitored; no download search starts automatically.
</p>
<div id="request-services" class="row" style="margin-bottom:12px"></div>
<div id="request-users" style="display:grid;gap:8px;margin-bottom:12px">
<span class="muted">Loading users…</span>
</div>
<button id="request-save">Save access</button>
</section>
<section id="maintenance" data-admin-page="maintenance">
<h2>Maintenance</h2>
<p class="muted" style="margin-top:0">
Takes Memby offline for every TV, independently of Emby. Sign-in and all content
@@ -119,7 +321,52 @@
</div>
</section>
<section>
<section id="features" data-admin-page="features">
<div id="feature-hero" class="feature-hero">
<div>
<p class="eyebrow" style="margin-bottom:4px">Server control plane</p>
<h2 style="color:var(--text);font-size:20px;text-transform:none;letter-spacing:-.01em;margin:0">Features without an app release</h2>
<p class="muted" style="margin:5px 0 0">Every optional feature has a safe default, an explicit override and a remote recovery path.</p>
<div id="feature-health" class="feature-health"><span>Loading feature state…</span></div>
</div>
<button id="feature-safe-mode" class="danger">Enable safe mode</button>
</div>
<div id="feature-list" class="feature-grid"><span class="muted">Loading flags…</span></div>
<div class="feature-actions">
<button id="feature-save">Publish changes</button>
<button id="feature-rollback" class="secondary">Roll back previous revision</button>
<button id="feature-reset" class="secondary">Clear all overrides</button>
<span id="feature-state" class="pill muted">loading…</span>
</div>
<h2 style="margin-top:24px">Client compatibility</h2>
<p class="muted">Capability reports are sent on every request. A feature is only presented to clients that declare its contract.</p>
<div class="scroll"><table>
<thead><tr><th>TV</th><th>User</th><th>App</th><th>Protocol</th><th>Control plane</th><th>Last seen</th></tr></thead>
<tbody id="feature-clients"><tr><td colspan="6" class="muted">No clients reported yet.</td></tr></tbody>
</table></div>
</section>
<section id="playback" data-admin-page="playback">
<h2>Playback experience</h2>
<p class="muted" style="margin-top:0">
Controls presentation policy returned with every playback launch. Changes apply to
the next title opened on every gateway-connected TV; no app release is required.
</p>
<div class="row">
<label style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="preroll-enabled"> Show the upcoming-show preroll
</label>
<label class="muted" style="display:flex;align-items:center;gap:8px">
Duration
<input type="number" id="preroll-duration" min="1" max="30" step="0.5" value="6.5" style="min-width:90px;width:90px">
seconds
</label>
<button id="playback-save">Save playback policy</button>
<span id="playback-state" class="pill"></span>
</div>
</section>
<section id="updates" data-admin-page="updates">
<h2>App updates</h2>
<p class="muted" style="margin-top:0">
TVs check on every launch. <strong>Optional</strong> shows a dismissable prompt;
@@ -142,7 +389,7 @@
</div>
</section>
<section>
<section id="engagement" data-admin-page="engagement">
<h2>Row engagement</h2>
<div class="row" style="margin-bottom:12px">
<label class="muted" for="days">Window</label>
@@ -167,7 +414,7 @@
</div>
</section>
<section>
<section id="imports" data-admin-page="imports">
<h2>Recent imports</h2>
<div class="scroll">
<table>
@@ -182,7 +429,7 @@
</div>
</section>
<section>
<section id="events" data-admin-page="logs">
<h2>Live server events</h2>
<div class="event-toolbar">
<select id="event-level" aria-label="Minimum event level">
@@ -204,6 +451,33 @@
</main>
<script>
const railLinks = [...document.querySelectorAll('.rail-link[data-section]')];
const currentAdminPage = location.pathname.split('/').filter(Boolean).pop() || 'library';
const pageCopy = {
library: ['Library', 'Import and inspect the catalogue Memby ranks.'],
recommendations: ['Recommendations', 'Pressure-test personalised rows and title scores per user.'],
requests: ['Media requests', 'Control who can request missing movies and shows.'],
features: ['Features', 'Roll out, stop and recover optional TV behaviour from the server.'],
playback: ['Playback', 'Control server-driven playback presentation on every TV.'],
maintenance: ['Maintenance', 'Control gateway availability for every television.'],
updates: ['App updates', 'Publish optional or mandatory client update policy.'],
engagement: ['Row engagement', 'Inspect impressions, focus, dwell and selections.'],
imports: ['Recent imports', 'Review catalogue synchronization history.'],
logs: ['Server logs', 'Follow structured gateway events in real time.'],
};
document.querySelectorAll('[data-admin-page]').forEach((section) => {
section.hidden = section.dataset.adminPage !== currentAdminPage;
});
railLinks.forEach((link) => {
const selected = link.dataset.section === currentAdminPage;
link.classList.toggle('active', selected);
if (selected) link.setAttribute('aria-current', 'page');
});
const copy = pageCopy[currentAdminPage] || pageCopy.library;
document.getElementById('page-title').textContent = copy[0];
document.getElementById('page-intro').textContent = copy[1];
document.title = copy[0] + ' · Memby admin';
function showError(message) {
const banner = document.getElementById('error');
banner.textContent = message || '';
@@ -242,6 +516,65 @@ const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) =>
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
}[char]));
let featureRevision = 0;
function renderFeatures(payload, clients) {
featureRevision = Number(payload.revision || 0);
const features = payload.features || [];
const enabled = features.filter((feature) => feature.enabled).length;
const overrides = features.filter((feature) => feature.source === 'override').length;
const capableClients = clients.filter((client) =>
(client.capabilities || []).includes('server_features_v1')).length;
const hero = document.getElementById('feature-hero');
hero.classList.toggle('safe', Boolean(payload.safeMode));
document.getElementById('feature-health').innerHTML =
'<div><b>' + enabled + ' / ' + features.length + '</b><span>features active</span></div>' +
'<div><b>' + overrides + '</b><span>explicit overrides</span></div>' +
'<div><b>' + capableClients + ' / ' + clients.length + '</b><span>TVs reporting control plane</span></div>' +
'<div><b>r' + featureRevision + '</b><span>published revision</span></div>';
const safeButton = document.getElementById('feature-safe-mode');
safeButton.textContent = payload.safeMode ? 'Leave safe mode' : 'Enable safe mode';
safeButton.className = payload.safeMode ? 'secondary' : 'danger';
const featureState = document.getElementById('feature-state');
featureState.textContent = payload.safeMode ? 'SAFE MODE · optional features off' : 'live · revision ' + featureRevision;
featureState.className = 'pill ' + (payload.safeMode ? 'warn' : 'ok');
document.getElementById('feature-rollback').disabled = !payload.canRollback;
const list = document.getElementById('feature-list');
if (!list.contains(document.activeElement)) {
list.innerHTML = features.map((feature) => {
const mode = feature.source === 'override' ? (feature.enabled ? 'on' : 'off') : 'default';
const effective = feature.enabled ? 'ACTIVE' : 'OFF';
return '<article class="feature-card">' +
'<div class="feature-card-head"><div><div class="feature-meta">' +
'<span class="pill ' + (feature.enabled ? 'ok' : 'muted') + '">' + effective + '</span>' +
'<span class="muted">' + escapeHtml(feature.area) + '</span></div>' +
'<h3>' + escapeHtml(feature.name) + '</h3><p>' + escapeHtml(feature.description) + '</p></div>' +
'<select class="feature-select" data-feature-key="' + escapeHtml(feature.key) + '" aria-label="' + escapeHtml(feature.name) + ' mode">' +
'<option value="default"' + (mode === 'default' ? ' selected' : '') + '>Safe default</option>' +
'<option value="on"' + (mode === 'on' ? ' selected' : '') + '>Forced on</option>' +
'<option value="off"' + (mode === 'off' ? ' selected' : '') + '>Forced off</option>' +
'</select></div>' +
'<div class="feature-meta"><code>' + escapeHtml(feature.key) + '</code>' +
'<span class="pill muted">protocol ' + number(feature.minimumProtocol) + '+</span>' +
'<span class="pill ' + (feature.compatible ? 'ok' : 'warn') + '">' +
(feature.compatible ? 'server compatible' : 'compatibility blocked') + '</span></div>' +
'<div class="recovery-note">↳ ' + escapeHtml(feature.recovery) + '</div></article>';
}).join('') || '<span class="muted">No server features are registered.</span>';
}
document.getElementById('feature-clients').innerHTML = clients.length
? clients.map((client) => {
const capable = (client.capabilities || []).includes('server_features_v1');
return '<tr><td>' + escapeHtml(client.deviceName || 'Memby TV') + '</td>' +
'<td>' + escapeHtml(client.username) + '</td><td>' + escapeHtml(client.version || 'legacy') + '</td>' +
'<td>' + escapeHtml(client.protocol || 'unknown') + '</td>' +
'<td><span class="pill ' + (capable ? 'ok' : 'warn') + '">' +
(capable ? 'reported' : 'legacy fallback') + '</span></td><td>' + escapeHtml(when(client.lastSeen)) + '</td></tr>';
}).join('')
: '<tr><td colspan="6" class="muted">No clients have signed in yet.</td></tr>';
}
function renderStatus(status) {
const byType = status.library.byType || {};
const types = Object.keys(byType).sort();
@@ -273,6 +606,34 @@ function renderStatus(status) {
document.getElementById('for-you-hint').textContent =
forYouRunning ? 'For You maintenance running…' : 'Prepared pools normally refresh in the background.';
document.getElementById('request-services').innerHTML =
'<span class="pill ' + (status.radarrReady ? 'ok' : 'bad') + '">Radarr ' +
(status.radarrReady ? 'ready' : 'not configured') + '</span>' +
'<span class="pill ' + (status.sonarrReady ? 'ok' : 'bad') + '">Sonarr ' +
(status.sonarrReady ? 'ready' : 'not configured') + '</span>';
const allowedUsers = new Set((status.requestPolicy || {}).allowedUserIds || []);
const userBox = document.getElementById('request-users');
if (!userBox.contains(document.activeElement)) {
userBox.innerHTML = (status.requestUsers || []).length
? status.requestUsers.map((user) =>
'<label style="display:flex;align-items:center;gap:9px">' +
'<input type="checkbox" data-request-user="' + escapeHtml(user.id) + '"' +
(allowedUsers.has(user.id) ? ' checked' : '') + '>' +
'<span>' + escapeHtml(user.username) + '</span>' +
'<span class="muted">last seen ' + escapeHtml(when(user.lastSeen)) + '</span></label>'
).join('')
: '<span class="muted">No users have signed in yet.</span>';
}
const recommendationUser = document.getElementById('recommendation-user');
if (!recommendationUser.contains(document.activeElement)) {
const selectedUser = recommendationUser.value;
recommendationUser.innerHTML = '<option value="">Choose a user…</option>' +
(status.requestUsers || []).map((user) =>
'<option value="' + escapeHtml(user.id) + '">' +
escapeHtml(user.username) + '</option>').join('');
recommendationUser.value = selectedUser;
}
const maintenance = status.maintenance || {};
const state = document.getElementById('maintenance-state');
state.textContent = maintenance.enabled ? 'OFFLINE' : 'online';
@@ -280,6 +641,23 @@ function renderStatus(status) {
const messageField = document.getElementById('maintenance-message');
if (document.activeElement !== messageField) messageField.value = maintenance.message || '';
renderFeatures(status.features || {}, status.clients || []);
const playback = status.playbackPolicy || {};
const prerollEnabled = document.getElementById('preroll-enabled');
const prerollDuration = document.getElementById('preroll-duration');
if (document.activeElement !== prerollEnabled) {
prerollEnabled.checked = playback.prerollEnabled !== false;
}
if (document.activeElement !== prerollDuration) {
prerollDuration.value = ((playback.prerollDurationMs || 6500) / 1000).toString();
}
const playbackState = document.getElementById('playback-state');
playbackState.textContent = prerollEnabled.checked
? 'on · ' + prerollDuration.value + 's'
: 'off';
playbackState.className = 'pill ' + (prerollEnabled.checked ? 'ok' : 'muted');
const policy = status.updatePolicy || {};
const policyState = document.getElementById('update-state');
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
@@ -337,21 +715,145 @@ function renderAnalytics(payload) {
: '<tr><td colspan="8" class="muted">No events in this window.</td></tr>';
}
function affinityEntries(profile) {
const dimensions = [
['Genre', profile.genres], ['Studio', profile.studios], ['Actor', profile.actors],
['Director', profile.directors], ['Franchise', profile.franchises],
['Runtime', profile.runtimeRanges], ['Age rating', profile.ageRatings],
['Community rating', profile.communityRatings], ['Release period', profile.releasePeriods],
['Content type', profile.contentTypes],
];
return dimensions.flatMap(([dimension, values]) =>
Object.entries(values || {}).map(([name, value]) => ({
dimension, name, weight: value.weight || 0, evidence: value.evidence || 0,
}))).sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight));
}
function renderRecommendationInspection(payload) {
const meta = payload.profileMeta || {};
document.getElementById('recommendation-summary').innerHTML =
'<div class="stat"><b>' + number(payload.poolCandidates) + '</b><span>prepared pool</span></div>' +
'<div class="stat"><b>' + number(payload.permissionEligible) + '</b><span>permission eligible</span></div>' +
'<div class="stat"><b>' + number((payload.items || []).length) + '</b><span>ranked result</span></div>' +
'<div class="stat"><b>' + number(meta.sourceEvents) + '</b><span>source events</span></div>' +
'<div class="stat"><b style="font-size:14px">' + escapeHtml(meta.algorithmVersion || '—') +
'</b><span>algorithm</span></div>' +
'<div class="stat"><b style="font-size:14px">' + when(meta.poolBuiltAt) +
'</b><span>pool built</span></div>';
const affinities = affinityEntries(payload.profile || {}).slice(0, 24);
const actions = payload.actions || [];
document.getElementById('recommendation-profile').innerHTML =
'<details><summary>Profile evidence · top affinities and explicit actions</summary>' +
'<div style="margin-top:8px">' +
(affinities.length ? affinities.map((entry) =>
'<span class="component ' + (entry.weight < 0 ? 'negative' : '') + '">' +
escapeHtml(entry.dimension + ': ' + entry.name) + ' ' +
(entry.weight >= 0 ? '+' : '') + entry.weight.toFixed(3) +
' · n=' + number(entry.evidence) + '</span>').join('') :
'<span class="muted">No repeated affinity evidence yet; cold-start priors apply.</span>') +
'</div><div style="margin-top:8px">' +
(actions.length ? actions.map((action) =>
'<span class="reason-code">' +
escapeHtml(action.action + ': ' + (action.title || action.itemId)) + '</span>').join('') :
'<span class="muted">No explicit recommendation actions.</span>') +
'</div></details>';
const results = payload.items || [];
document.getElementById('recommendation-results').innerHTML = results.length
? results.map((item, index) => {
const explanation = item.explanation || {};
const components = Object.entries(explanation.components || {})
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
const codes = explanation.reasonCodes || [];
const exposure = item.exposure || {};
return '<article class="recommendation-card">' +
'<div class="recommendation-head"><div>' +
'<div class="recommendation-title">#' + (index + 1) + ' · ' + escapeHtml(item.title) + '</div>' +
'<div class="recommendation-meta">' +
escapeHtml(item.type) + (item.year ? ' · ' + item.year : '') +
(item.runtimeMinutes ? ' · ' + item.runtimeMinutes + ' min' : '') +
(item.genres || []).map((genre) => ' · ' + escapeHtml(genre)).join('') +
'</div></div>' +
'<div class="recommendation-score">' + Number(explanation.total || 0).toFixed(3) + '</div>' +
'</div>' +
'<div class="reason-text">' +
escapeHtml(item.preparedReason || 'No legacy prepared explanation') +
(item.compatibilityLabel ? ' · ' + escapeHtml(item.compatibilityLabel) : '') +
'</div><div>' +
codes.map((code) => '<span class="reason-code">' + escapeHtml(code) + '</span>').join('') +
'</div><div>' +
components.map(([name, value]) =>
'<span class="component ' + (value < 0 ? 'negative' : '') + '">' +
escapeHtml(name) + '=' + (value >= 0 ? '+' : '') + Number(value).toFixed(3) +
'</span>').join('') +
'</div><details><summary>Pool, row and exposure details</summary>' +
'<div class="recommendation-meta">Base rank ' + number(item.baseRank) +
' · base ' + Number(item.baseScore || 0).toFixed(3) +
' · affinity ' + Number(item.affinityScore || 0).toFixed(3) +
' · compatibility ' + Number(item.compatibilityScore || 0).toFixed(3) +
' · impressions ' + number(exposure.impressions) +
' · focuses ' + number(exposure.focuses) +
' · selects ' + number(exposure.selects) + '</div>' +
'<div style="margin-top:6px">' + (item.eligibleRows || []).map((row) =>
'<span class="reason-code">' + escapeHtml(row) + '</span>').join('') + '</div>' +
(item.preparedEvidenceTitle ?
'<div class="recommendation-meta">Prepared evidence: ' +
escapeHtml(item.preparedEvidenceTitle) + '</div>' : '') +
'</details></article>';
}).join('')
: '<span class="muted">No candidates survived this context, explicit exclusions and permission filter.</span>';
}
async function loadRecommendationInspection() {
const userId = document.getElementById('recommendation-user').value;
if (!userId) {
showError('Choose a user to pressure-test.');
return;
}
const params = new URLSearchParams({
userId,
context: document.getElementById('recommendation-context').value,
minutes: document.getElementById('recommendation-minutes').value || '0',
limit: '100',
});
const localAt = document.getElementById('recommendation-at').value;
if (localAt) params.set('at', new Date(localAt).toISOString());
document.getElementById('recommendation-results').innerHTML =
'<span class="muted">Running permission check and scorer…</span>';
try {
renderRecommendationInspection(await api('/admin/api/recommendations?' + params.toString()));
showError('');
} catch (err) {
showError(err.message);
document.getElementById('recommendation-results').innerHTML =
'<span class="muted">Pressure test failed.</span>';
}
}
async function refresh() {
const live = document.getElementById('live');
const railLive = document.getElementById('rail-live');
try {
const [status, analytics] = await Promise.all([
api('/admin/api/status'),
api('/admin/api/analytics?days=' + document.getElementById('days').value),
currentAdminPage === 'engagement'
? api('/admin/api/analytics?days=' + document.getElementById('days').value)
: Promise.resolve(null),
]);
renderStatus(status);
renderAnalytics(analytics);
live.textContent = 'updated ' + new Date().toLocaleTimeString();
if (analytics) renderAnalytics(analytics);
const updatedAt = new Date().toLocaleTimeString();
live.textContent = 'updated ' + updatedAt;
live.className = 'pill ok';
railLive.textContent = 'online';
railLive.className = 'pill ok';
showError('');
} catch (err) {
live.textContent = 'error';
live.className = 'pill bad';
railLive.textContent = 'offline';
railLive.className = 'pill bad';
showError(err.message);
}
}
@@ -390,6 +892,16 @@ document.getElementById('for-you-full').addEventListener('click', () => {
document.getElementById('for-you-rebuild').addEventListener('click', () =>
act(() => forYouAction('rebuild-all')));
document.getElementById('recommendation-load').addEventListener('click', loadRecommendationInspection);
document.getElementById('request-save').addEventListener('click', () => {
const allowedUserIds = [...document.querySelectorAll('[data-request-user]:checked')]
.map((box) => box.dataset.requestUser);
act(() => api('/admin/api/request-policy', {
method: 'POST',
body: JSON.stringify({ allowedUserIds }),
}));
});
document.getElementById('maintenance-on').addEventListener('click', () => {
if (!confirm('Take Memby offline for every TV?')) return;
@@ -405,6 +917,53 @@ document.getElementById('maintenance-off').addEventListener('click', () =>
body: JSON.stringify({ enabled: false, message: document.getElementById('maintenance-message').value }),
})));
function featureAction(action, overrides = {}) {
return api('/admin/api/features', {
method: 'POST',
body: JSON.stringify({ action, expectedRevision: featureRevision, overrides }),
});
}
document.getElementById('feature-save').addEventListener('click', () => {
const overrides = {};
document.querySelectorAll('[data-feature-key]').forEach((select) => {
if (select.value === 'on') overrides[select.dataset.featureKey] = true;
if (select.value === 'off') overrides[select.dataset.featureKey] = false;
});
act(() => featureAction('save', overrides));
});
document.getElementById('feature-safe-mode').addEventListener('click', () => {
const leaving = document.getElementById('feature-safe-mode').textContent.startsWith('Leave');
if (!leaving && !confirm('Disable every optional feature immediately? Core sign-in, browsing and playback remain available.')) return;
act(() => featureAction(leaving ? 'leave-safe-mode' : 'safe-mode'));
});
document.getElementById('feature-rollback').addEventListener('click', () => {
if (!confirm('Restore the previous published feature revision?')) return;
act(() => featureAction('rollback'));
});
document.getElementById('feature-reset').addEventListener('click', () => {
if (!confirm('Clear every override and return all features to their safe software defaults?')) return;
act(() => featureAction('reset'));
});
document.getElementById('playback-save').addEventListener('click', () => {
const durationSeconds = Number(document.getElementById('preroll-duration').value);
if (!Number.isFinite(durationSeconds) || durationSeconds < 1 || durationSeconds > 30) {
showError('Preroll duration must be between 1 and 30 seconds.');
return;
}
act(() => api('/admin/api/playback-policy', {
method: 'POST',
body: JSON.stringify({
prerollEnabled: document.getElementById('preroll-enabled').checked,
prerollDurationMs: Math.round(durationSeconds * 1000),
}),
}));
});
function updatePolicyBody(enabled) {
return JSON.stringify({
enabled,
@@ -515,8 +1074,10 @@ document.getElementById('event-search').addEventListener('input', renderEvents);
refresh();
setInterval(refresh, 5000);
pollEvents();
setInterval(pollEvents, 5 * 60 * 1000);
if (currentAdminPage === 'logs') {
pollEvents();
setInterval(pollEvents, 5 * 60 * 1000);
}
</script>
</body>
</html>
@@ -0,0 +1,242 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
type adminRecommendationItem struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Year int `json:"year,omitempty"`
Genres []string `json:"genres"`
RuntimeMinutes int `json:"runtimeMinutes"`
BaseRank int `json:"baseRank"`
BaseScore float64 `json:"baseScore"`
AffinityScore float64 `json:"affinityScore"`
CompatibilityScore float64 `json:"compatibilityScore"`
CompatibilityLabel string `json:"compatibilityLabel,omitempty"`
PreparedReason string `json:"preparedReason,omitempty"`
PreparedReasonKind string `json:"preparedReasonKind,omitempty"`
PreparedReasonGenre string `json:"preparedReasonGenre,omitempty"`
PreparedEvidenceTitle string `json:"preparedEvidenceTitle,omitempty"`
EligibleRows []string `json:"eligibleRows"`
Exposure recommend.ItemExposure `json:"exposure"`
Explanation recommend.ScoreExplanation `json:"explanation"`
}
type adminRecommendationProfileMeta struct {
SourceEvents int `json:"sourceEvents"`
BuiltAt *time.Time `json:"builtAt,omitempty"`
PoolBuiltAt *time.Time `json:"poolBuiltAt,omitempty"`
DirtySince *time.Time `json:"dirtySince,omitempty"`
AlgorithmVersion string `json:"algorithmVersion"`
}
type adminRecommendationAction struct {
ItemID string `json:"itemId"`
Title string `json:"title,omitempty"`
Action string `json:"action"`
UpdatedAt time.Time `json:"updatedAt"`
}
type adminRecommendationResponse struct {
User store.KnownUser `json:"user"`
Context string `json:"context"`
GeneratedAt time.Time `json:"generatedAt"`
Profile recommend.WeightedProfile `json:"profile"`
ProfileMeta adminRecommendationProfileMeta `json:"profileMeta"`
Actions []adminRecommendationAction `json:"actions"`
PoolCandidates int `json:"poolCandidates"`
PermissionEligible int `json:"permissionEligible"`
Items []adminRecommendationItem `json:"items"`
}
func (s *Server) handleAdminRecommendations(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.URL.Query().Get("userId"))
if userID == "" {
writeError(w, http.StatusBadRequest, "userId is required")
return
}
users, err := s.store.KnownUsers(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load users")
return
}
var user store.KnownUser
found := false
for _, value := range users {
if value.ID == userID {
user, found = value, true
break
}
}
if !found {
writeError(w, http.StatusNotFound, "unknown user")
return
}
sessions, err := s.store.ActiveRecommendationUsers(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load user session")
return
}
var session store.Session
for _, value := range sessions {
if value.EmbyUserID == userID {
session = value
break
}
}
if session.EmbyUserID == "" {
writeError(w, http.StatusConflict, "user has no active Emby session")
return
}
prepared, _, err := s.store.PreparedForYou(r.Context(), userID, 0, 500)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load recommendation pool")
return
}
poolCount := len(prepared)
permissionRow := recommend.Row{ID: "for-you:admin-pressure-test", Kind: "for-you"}
for _, item := range prepared {
permissionRow.Items = append(permissionRow.Items, item.Payload)
}
filtered := s.filterRecommendationPermissions(
r.Context(), session, []recommend.Row{permissionRow},
)
allowed := map[string]bool{}
if len(filtered) == 1 {
for _, item := range recommend.Decode(filtered[0].Items) {
allowed[item.ID] = true
}
}
profile, exposures, household := s.rankingContext(r.Context(), userID)
now := time.Now()
if raw := strings.TrimSpace(r.URL.Query().Get("at")); raw != "" {
if parsed, parseErr := time.Parse(time.RFC3339, raw); parseErr == nil {
now = parsed
}
}
contextName := strings.TrimSpace(r.URL.Query().Get("context"))
if contextName == "" {
contextName = "default"
}
intent := recommend.RankIntent{
ID: "admin:" + contextName, Now: now, Location: s.cfg.SonarrLocation,
HouseholdScores: household, Compatibility: map[string]float64{},
}
switch contextName {
case "default":
case "bedtime":
intent.PreferShort, intent.MaxRuntimeMins = true, 60
case "hidden":
intent.HiddenLibrary, intent.UnseenOnly = true, true
case "new-releases":
intent.NewReleasesOnly = true
default:
writeError(w, http.StatusBadRequest, "unknown pressure-test context")
return
}
if minutes := queryInt(r, "minutes", 0, 360); minutes > 0 {
intent.MaxRuntimeMins = minutes
}
candidates := make([]recommend.Item, 0, len(prepared))
byID := make(map[string]store.PreparedForYouItem, len(prepared))
for _, item := range prepared {
if !allowed[item.ItemID] {
continue
}
decoded := recommend.Decode([]json.RawMessage{item.Payload})
if len(decoded) != 1 {
continue
}
candidates = append(candidates, decoded[0])
byID[item.ItemID] = item
intent.Compatibility[item.ItemID] = item.CompatibilityScore
}
limit := queryInt(r, "limit", 100, 200)
ranked := recommend.WeightedRank(
profile, candidates, exposures, intent, s.weightedConfig(), limit,
)
items := make([]adminRecommendationItem, 0, len(ranked))
for _, value := range ranked {
preparedItem := byID[value.Item.ID]
items = append(items, adminRecommendationItem{
ID: value.Item.ID, Title: value.Item.Name, Type: value.Item.Type,
Year: value.Item.ProductionYear, Genres: value.Item.Genres,
RuntimeMinutes: value.Item.RuntimeMinutes(),
BaseRank: preparedItem.BaseRank, BaseScore: preparedItem.BaseScore,
AffinityScore: preparedItem.AffinityScore,
CompatibilityScore: preparedItem.CompatibilityScore,
CompatibilityLabel: preparedItem.CompatibilityLabel,
PreparedReason: preparedItem.RecommendationReason,
PreparedReasonKind: preparedItem.ReasonKind,
PreparedReasonGenre: preparedItem.ReasonGenre,
PreparedEvidenceTitle: preparedItem.ReasonSourceTitle,
EligibleRows: adminEligibleRows(preparedItem, value.Item),
Exposure: exposures[value.Item.ID], Explanation: value.Explanation,
})
}
storedActions, _ := s.store.RecommendationActions(r.Context(), userID)
actionIDs := make([]string, 0, len(storedActions))
for _, action := range storedActions {
actionIDs = append(actionIDs, action.ItemID)
}
actionTitles := map[string]string{}
if raws, actionErr := s.store.LibraryItemsByID(r.Context(), actionIDs); actionErr == nil {
for _, item := range recommend.Decode(raws) {
actionTitles[item.ID] = item.Name
}
}
actions := make([]adminRecommendationAction, 0, len(storedActions))
for _, action := range storedActions {
actions = append(actions, adminRecommendationAction{
ItemID: action.ItemID, Title: actionTitles[action.ItemID],
Action: action.Action, UpdatedAt: action.UpdatedAt,
})
}
builtAt, poolBuiltAt, dirtySince, version, _ :=
s.store.ForYouProfileTimes(r.Context(), userID)
writeJSON(w, http.StatusOK, adminRecommendationResponse{
User: user, Context: contextName, GeneratedAt: now, Profile: profile,
ProfileMeta: adminRecommendationProfileMeta{
SourceEvents: profile.SourceEvents, BuiltAt: builtAt,
PoolBuiltAt: poolBuiltAt, DirtySince: dirtySince, AlgorithmVersion: version,
},
Actions: actions, PoolCandidates: poolCount,
PermissionEligible: len(candidates), Items: items,
})
}
func adminEligibleRows(item store.PreparedForYouItem, decoded recommend.Item) []string {
rows := []string{"Top picks for you"}
if item.ReasonKind == "pick-up" {
rows = append(rows, "Pick this show up again")
}
if item.ReasonSourceTitle != "" {
rows = append(rows, "Because you finished "+item.ReasonSourceTitle)
}
if item.ReasonGenre != "" {
rows = append(rows, "More "+item.ReasonGenre+" for you")
}
if item.CompatibilityScore > 0.2 {
rows = append(rows, "Plays well on this TV")
}
if decoded.Type == "Series" && item.RuntimeMinutes >= 15 && item.RuntimeMinutes <= 50 {
rows = append(rows, "One episode before bed")
}
if item.ReasonKind != "pick-up" {
rows = append(rows, "Hidden in your library")
}
return rows
}
+168 -9
View File
@@ -6,10 +6,13 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -81,7 +84,9 @@ func TestMaintenanceGateFallsBackToADefaultMessage(t *testing.T) {
func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
// Health checks and the admin page sit outside the gate on purpose: they are what
// you need most while the app is deliberately down.
server := testServer(config.Config{AdminToken: "secret"})
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
server.maintenance.set(store.Maintenance{Enabled: true})
rec := httptest.NewRecorder()
@@ -92,8 +97,9 @@ func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
rec = httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/", nil))
if rec.Code != http.StatusOK {
t.Fatalf("admin page should stay reachable, got %d", rec.Code)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/features" {
t.Fatalf("admin root should stay reachable via library redirect, got %d %q",
rec.Code, rec.Header().Get("Location"))
}
}
@@ -166,6 +172,17 @@ func TestAdminIsDisabledWithoutAToken(t *testing.T) {
}
}
func TestPlaybackPolicyRejectsUnsafeDuration(t *testing.T) {
server := testServer(config.Config{})
req := httptest.NewRequest(http.MethodPost, "/admin/api/playback-policy",
strings.NewReader(`{"prerollEnabled":true,"prerollDurationMs":500}`))
rec := httptest.NewRecorder()
server.handleAdminPlaybackPolicy(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestAdminAuthRejectsAWrongToken(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"})
handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) {
@@ -198,12 +215,45 @@ func TestAdminAuthRejectsAWrongToken(t *testing.T) {
}
}
func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
func TestAdminRuntimeMetricsAreProtectedAndReportHeap(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"})
req := httptest.NewRequest(http.MethodGet, "https://memby.local/admin/", nil)
unauthorized := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(
unauthorized,
httptest.NewRequest(http.MethodGet, "/admin/api/runtime", nil),
)
if unauthorized.Code != http.StatusUnauthorized {
t.Fatalf("unauthorized runtime status = %d", unauthorized.Code)
}
req := httptest.NewRequest(http.MethodGet, "/admin/api/runtime", nil)
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("runtime status = %d: %s", rec.Code, rec.Body.String())
}
var body adminRuntimeStatus
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Goroutines < 1 || body.HeapInuse == 0 || body.MemoryLimit <= 0 {
t.Fatalf("runtime metrics = %+v", body)
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("cache control = %q", rec.Header().Get("Cache-Control"))
}
}
func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodGet, "https://memby.local/admin/library", nil)
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
server.handleAdminPage(rec, req)
server.adminRoutes().ServeHTTP(rec, req)
result := rec.Result()
cookies := result.Cookies()
@@ -223,7 +273,116 @@ func TestAdminPageEstablishesPersistentCookie(t *testing.T) {
}
func TestAdminAuthAcceptsPersistentCookie(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"})
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: "secret"})
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("the admin cookie should be accepted, got %d", rec.Code)
}
}
func TestAdminPageRequiresDiscreetEmbyGate(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("admin gate status = %d", rec.Code)
}
body := strings.ToLower(rec.Body.String())
for _, forbidden := range []string{"memby", "emby", "installer", "administration", "analytics"} {
if strings.Contains(body, forbidden) {
t.Fatalf("admin gate disclosed %q before login", forbidden)
}
}
if !strings.Contains(body, "login required to continue.") ||
!strings.Contains(body, `name="next" type="hidden" value="/admin/logs"`) {
t.Fatalf("admin gate has wrong copy or return destination: %s", rec.Body.String())
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("admin gate issued an admin cookie before Emby authentication")
}
}
func TestAdminPagesUseRealRoutes(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
for _, page := range []string{
"library", "recommendations", "requests", "maintenance",
"updates", "engagement", "imports", "logs",
} {
req := httptest.NewRequest(http.MethodGet, "/admin/"+page, nil)
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("/admin/%s = %d", page, rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, `href="/admin/logs"`) ||
!strings.Contains(body, `data-admin-page="`+page+`"`) {
t.Fatalf("/admin/%s does not contain routed navigation/page marker", page)
}
}
req := httptest.NewRequest(http.MethodGet, "/admin/not-a-page", nil)
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("unknown admin page = %d", rec.Code)
}
}
func TestInstallerDestinationAllowsOnlyKnownAdminPages(t *testing.T) {
if got := cleanInstallerDestination("/admin/recommendations"); got != "/admin/recommendations" {
t.Fatalf("recommendation destination = %q", got)
}
for _, unsafe := range []string{
"/admin/not-real", "/admin/../updates/latest.apk", "/admin/logs?next=https://evil.test",
} {
if got := cleanInstallerDestination(unsafe); got != "/install" {
t.Fatalf("unsafe destination %q accepted as %q", unsafe, got)
}
}
}
func TestAdminEligibleRowsExplainPlacement(t *testing.T) {
item := store.PreparedForYouItem{
RuntimeMinutes: 28, CompatibilityScore: 0.8,
ReasonKind: "completed-title", ReasonGenre: "Drama",
ReasonSourceTitle: "Arrival",
}
rows := adminEligibleRows(item, recommend.Item{Type: "Series"})
for _, wanted := range []string{
"Top picks for you", "Because you finished Arrival", "More Drama for you",
"Plays well on this TV", "One episode before bed", "Hidden in your library",
} {
if !slices.Contains(rows, wanted) {
t.Fatalf("eligible rows %v missing %q", rows, wanted)
}
}
}
func TestAdminCookieCannotOutliveEmbyGate(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
@@ -233,8 +392,8 @@ func TestAdminAuthAcceptsPersistentCookie(t *testing.T) {
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("the admin cookie should be accepted, got %d", rec.Code)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("admin cookie without Emby gate got %d, want 401", rec.Code)
}
}
+152 -5
View File
@@ -11,7 +11,11 @@ import (
// Kinds are part of the wire contract: an unknown kind is rendered with the generic
// banner rather than dropped, so adding one never needs an app release.
const (
alertKindSonarrAired = "sonarr-aired"
alertKindSonarrAired = "sonarr-aired"
alertKindRadarrImport = "radarr-import"
alertKindLibrarySync = "library-updated"
alertKindServerDown = "server-unreachable"
alertKindServerUp = "server-restored"
// A TV shows one banner at a time; more than a few queued up is noise, not news.
maxAlerts = 3
@@ -21,16 +25,158 @@ const (
// the only channel the app already listens to while it is open. It carries no action:
// the client slides it in, shows it for a few seconds and forgets it.
type clientAlert struct {
ID string `json:"id"`
Kind string `json:"kind"`
ID string `json:"id"`
Kind string `json:"kind"`
// Label is the banner's eyebrow ("JUST AIRED", "NEW MOVIE ADDED"). It travels with
// the alert so a new kind of news reads correctly on an app that predates it; a
// client seeing no label falls back to its own wording.
Label string `json:"label,omitempty"`
Title string `json:"title"`
Message string `json:"message"`
ItemID string `json:"itemId,omitempty"`
ImageTag string `json:"imageTag,omitempty"`
AiredAt string `json:"airedAt,omitempty"`
// AiredAt is when the news happened — broadcast time for an episode, import time
// for a movie. It orders the merged list; the client does not read it.
AiredAt string `json:"airedAt,omitempty"`
}
// sonarrAiredAlerts reads the day's calendar through the same cache the airing-today row
// mergeAlerts interleaves the alert sources newest-first and trims to what a TV can
// usefully show. Each source is already sorted, but a movie imported ten minutes ago
// should still come before an episode that aired two hours back.
func mergeAlerts(groups ...[]clientAlert) []clientAlert {
merged := make([]clientAlert, 0, maxAlerts)
for _, group := range groups {
merged = append(merged, group...)
}
sort.SliceStable(merged, func(i, j int) bool {
return alertTime(merged[i]).After(alertTime(merged[j]))
})
if len(merged) > maxAlerts {
merged = merged[:maxAlerts]
}
return merged
}
// alertTime is zero for an alert with no timestamp, which sorts it last rather than
// dropping it — an unorderable alert is still news.
func alertTime(alert clientAlert) time.Time {
when, err := time.Parse(time.RFC3339, alert.AiredAt)
if err != nil {
return time.Time{}
}
return when
}
// Alerts that are *events* — a film imported, the library refreshed, Emby stopping and
// starting to answer — are published into one shared Redis list and served from it,
// rather than derived on each poll the way the Sonarr calendar is.
//
// A list with expiring entries rather than a push: the gateway holds no connection to a
// television, so there is nothing to push to. A window is what lets a set that was off
// or in the screensaver when the event happened still hear about it, and each TV dedupes
// by alert id so nobody is told twice.
const publishedAlertsCacheKey = "alerts:events:v1"
// More than this stored at once means something is generating events in bulk, and a
// viewer does not want twenty banners about it — the newest few are the news.
const maxStoredAlerts = 8
// storedAlert is a clientAlert with the moment it stops being news. Expiry is held with
// the record rather than as a Redis TTL because they share one key: the list outlives
// any single entry in it.
type storedAlert struct {
Alert clientAlert `json:"alert"`
ExpiresAt time.Time `json:"expiresAt"`
}
// publishAlert makes one event visible to every signed-in TV for window.
//
// Failures are logged and swallowed. A missed banner is not worth failing the thing that
// produced it — an import, a library sync, a health probe — none of which the viewer
// would want retried for the sake of a notice.
func (s *Server) publishAlert(ctx context.Context, alert clientAlert, window time.Duration) {
if window <= 0 || alert.ID == "" || s.cache == nil {
return
}
// Read-modify-write on one key, so producers running on their own schedules need
// serialising against each other. They are rare enough that a mutex is the whole
// answer; this instance is the only writer.
s.alertMu.Lock()
defer s.alertMu.Unlock()
now := time.Now().UTC()
stored := appendAlert(s.storedAlerts(ctx), alert, now.Add(window), now)
body, err := json.Marshal(stored)
if err != nil {
s.log.Warn("alert encode failed", "error", err)
return
}
// The key's own TTL is a floor sweep for a gateway that stops producing events; the
// per-entry expiry is what actually decides what a client sees.
if err := s.cache.Set(ctx, publishedAlertsCacheKey, body, window*2); err != nil {
s.log.Warn("alert store failed", "error", err)
}
}
// appendAlert is the pure half of publishing: prune what has expired, replace any earlier
// copy of the same alert rather than stacking duplicates, keep the newest few.
func appendAlert(
existing []storedAlert, alert clientAlert, expiresAt, now time.Time,
) []storedAlert {
kept := make([]storedAlert, 0, len(existing)+1)
for _, entry := range existing {
if entry.Alert.ID == alert.ID || !entry.ExpiresAt.After(now) {
continue
}
kept = append(kept, entry)
}
kept = append(kept, storedAlert{Alert: alert, ExpiresAt: expiresAt})
sort.SliceStable(kept, func(i, j int) bool {
return alertTime(kept[i].Alert).After(alertTime(kept[j].Alert))
})
if len(kept) > maxStoredAlerts {
kept = kept[:maxStoredAlerts]
}
return kept
}
// publishedAlerts is the read side, called on every /v1/status poll.
func (s *Server) publishedAlerts(ctx context.Context) []clientAlert {
return liveAlerts(s.storedAlerts(ctx), time.Now().UTC())
}
func (s *Server) storedAlerts(ctx context.Context) []storedAlert {
// Redis is where these live, so no Redis means no news — never a failed status poll.
// The status endpoint is what a client falls back on when everything else is broken;
// it must not acquire a hard dependency for the sake of a banner.
if s.cache == nil {
return nil
}
raw, err := s.cache.Get(ctx, publishedAlertsCacheKey)
if err != nil {
return nil
}
var stored []storedAlert
if json.Unmarshal(raw, &stored) != nil {
return nil
}
return stored
}
// liveAlerts drops entries whose window has closed. Expiry is applied on read as well as
// on write, so an alert stops being offered on time even if no event follows it.
func liveAlerts(stored []storedAlert, now time.Time) []clientAlert {
alerts := make([]clientAlert, 0, len(stored))
for _, entry := range stored {
if !entry.ExpiresAt.After(now) {
continue
}
alerts = append(alerts, entry.Alert)
}
return alerts
}
// sonarrAiredAlerts reads the calendar through the same cache the five-day schedule row
// uses, so polling clients never cost a Sonarr request of their own.
func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
if s.sonarr == nil || s.cfg.SonarrAlertWindow <= 0 {
@@ -92,6 +238,7 @@ func buildSonarrAlerts(items []sonarrScheduleItem, now time.Time, window time.Du
alert: clientAlert{
ID: item.ID + ":aired",
Kind: alertKindSonarrAired,
Label: "JUST AIRED",
Title: item.Name,
Message: message,
ItemID: item.ID,
+10
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"time"
@@ -58,6 +59,15 @@ func (s *Server) handleRowAnalytics(w http.ResponseWriter, r *http.Request, sess
s.log.Warn("row analytics write failed", "error", err)
// Still a 204: telemetry must never make the TV think something is broken.
}
for _, event := range events {
if event.Event == store.RowEventSelect {
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
if s.forYou != nil {
s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess)
}
break
}
}
w.WriteHeader(http.StatusNoContent)
}
+48 -5
View File
@@ -16,6 +16,7 @@ import (
"errors"
"log/slog"
"net/http"
"slices"
"strconv"
"strings"
"sync"
@@ -26,6 +27,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/foryou"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
@@ -39,10 +41,16 @@ type Server struct {
recommender *recommend.Engine
forYou *foryou.Service
sonarr *sonarr.Client
radarr *radarr.Client
syncer syncerHandle
log *slog.Logger
events *serverlogging.Buffer
sonarrMu sync.Mutex
radarrMu sync.Mutex
// alertMu serialises the read-modify-write of the shared alert list. Its producers
// are events — a webhook, a finished sync, a health probe — none of them paced by
// this server, so two can land at once.
alertMu sync.Mutex
recommendationBuilds recommendationBuilds
maintenance maintenanceState
@@ -58,15 +66,13 @@ type Deps struct {
Recommender *recommend.Engine
ForYou *foryou.Service
Sonarr *sonarr.Client
Radarr *radarr.Client
Syncer syncerHandle
Log *slog.Logger
Events *serverlogging.Buffer
}
func New(cfg config.Config, deps Deps) *Server {
if cfg.MaxClientsPerUser < 1 {
cfg.MaxClientsPerUser = 1
}
return &Server{
cfg: cfg,
emby: deps.Emby,
@@ -75,6 +81,7 @@ func New(cfg config.Config, deps Deps) *Server {
recommender: deps.Recommender,
forYou: deps.ForYou,
sonarr: deps.Sonarr,
radarr: deps.Radarr,
syncer: deps.Syncer,
log: deps.Log,
events: deps.Events,
@@ -87,22 +94,38 @@ func (s *Server) Routes() http.Handler {
v1 := http.NewServeMux()
v1.HandleFunc("POST /v1/auth/login", s.handleLogin)
v1.HandleFunc("GET /v1/auth/policy", s.handleAuthPolicy)
v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout))
v1.Handle("GET /v1/auth/session", s.authed(s.handleSession))
v1.Handle("GET /v1/auth/devices", s.authed(s.handleDevices))
v1.Handle("PUT /v1/auth/devices/{deviceID}", s.authed(s.handleRenameDevice))
v1.Handle("DELETE /v1/auth/devices/{deviceID}", s.authed(s.handleDeleteDevice))
v1.Handle("GET /v1/home", s.authed(s.handleHome))
v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver))
v1.Handle("GET /v1/search", s.authed(s.handleSearch))
v1.Handle("GET /v1/search/history", s.authed(s.handleRecentSearches))
v1.Handle("POST /v1/search/history", s.authed(s.handleSearchHistory))
v1.Handle("GET /v1/requests/lookup", s.authed(s.handleRequestLookup))
v1.Handle("POST /v1/requests", s.authed(s.handleRequest))
v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations))
v1.Handle("PUT /v1/recommendations/{id}/action", s.authed(s.handleRecommendationAction))
v1.Handle("DELETE /v1/recommendations/{id}/action", s.authed(s.handleRecommendationAction))
v1.Handle("PUT /v1/recommendations/preferences", s.authed(s.handleRecommendationPreferences))
v1.Handle("GET /v1/recommendations/preferences", s.authed(s.handleRecommendationPreferences))
v1.Handle("GET /v1/for-you", s.authed(s.handleForYou))
v1.Handle("GET /v1/preroll", s.authed(s.handlePreroll))
v1.Handle("GET /v1/update", s.authed(s.handleUpdate))
v1.Handle("GET /v1/my-shows", s.authed(s.handleMyShows))
v1.Handle("POST /v1/my-shows", s.authed(s.handleMyShows))
v1.Handle("DELETE /v1/my-shows/{id}", s.authed(s.handleMyShow))
v1.Handle("GET /v1/notifications", s.authed(s.handleNotifications))
v1.Handle("PUT /v1/notifications", s.authed(s.handleNotifications))
v1.Handle("POST /v1/notifications/{id}/{action}", s.authed(s.handleNotificationAction))
v1.Handle("GET /v1/features", s.authed(s.handleFeatures))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("GET /v1/items/{id}/season-finale", s.authed(s.handleSeasonFinale))
v1.Handle("GET /v1/items/{id}/episodes", s.authed(s.handleSeriesEpisodes))
v1.Handle("GET /v1/items/{id}/related", s.authed(s.handleRelated))
v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite))
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback))
@@ -117,11 +140,25 @@ func (s *Server) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.handleHealth)
mux.HandleFunc("GET /readyz", s.handleReady)
// Update policy is app-scoped, not user-scoped. Keep it outside authentication and
// maintenance so a fresh install, a signed-out TV, and a retired build can all learn
// whether the server requires an update without touching a viewer session.
mux.HandleFunc("GET /v1/update", s.handleUpdate)
// Exact route outside the maintenance gate: signed-in clients poll this lightweight
// status even while every normal /v1 operation is deliberately unavailable.
mux.Handle("GET /v1/status", s.authed(s.handleServiceStatus))
mux.Handle("/v1/", s.maintenanceGate(v1))
// Radarr pushes here when an import finishes. Outside the gate on purpose: an event
// arriving during maintenance would otherwise be lost rather than delayed.
mux.HandleFunc("POST /hooks/radarr", s.handleRadarrWebhook)
mux.Handle("/admin/", s.adminRoutes())
mux.HandleFunc("GET /{$}", s.handleInstallPage)
mux.HandleFunc("GET /install", s.handleInstallPage)
mux.HandleFunc("GET /install/{$}", s.handleInstallPage)
mux.HandleFunc("POST /install/login", s.handleInstallLogin)
mux.HandleFunc("POST /install/logout", s.handleInstallLogout)
mux.HandleFunc("GET /robots.txt", handleRobots)
mux.HandleFunc("GET /updates/latest.apk", s.handleLatestReleaseDownload)
mux.HandleFunc("GET /updates/{filename}", s.handleReleaseDownload)
return s.withLogging(mux)
@@ -165,6 +202,7 @@ func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) stor
if changed {
if err := s.store.UpdateSessionClientIdentity(
r.Context(), sess.TokenHash, sess.ClientVersion, sess.ClientProtocol,
sess.ClientCapabilities,
); err != nil {
s.log.Warn("client identity update failed", "error", err)
} else {
@@ -177,6 +215,7 @@ func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) stor
func mergeClientIdentity(r *http.Request, sess *store.Session) bool {
version := clientVersion(r)
protocol := clientProtocol(r)
capabilities := clientCapabilities(r)
changed := false
if version != "" && version != sess.ClientVersion {
sess.ClientVersion = version
@@ -186,6 +225,10 @@ func mergeClientIdentity(r *http.Request, sess *store.Session) bool {
sess.ClientProtocol = protocol
changed = true
}
if len(capabilities) > 0 && !slices.Equal(capabilities, sess.ClientCapabilities) {
sess.ClientCapabilities = capabilities
changed = true
}
if version == "" && sess.ClientVersion != "" {
r.Header.Set("X-Memby-Version", sess.ClientVersion)
}
+54 -12
View File
@@ -4,11 +4,12 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -60,22 +61,47 @@ func TestNewTokenIsUnique(t *testing.T) {
}
}
func TestAuthPolicyPublishesConfiguredDeviceAllowance(t *testing.T) {
s := &Server{cfg: config.Config{MaxClientsPerUser: 4}}
req := httptest.NewRequest(http.MethodGet, "/v1/auth/policy", nil)
func TestRenameDeviceRejectsBlankName(t *testing.T) {
s := &Server{}
req := httptest.NewRequest(http.MethodPut, "/v1/auth/devices/tv-1", strings.NewReader(`{"deviceName":" "}`))
req.SetPathValue("deviceID", "tv-1")
rec := httptest.NewRecorder()
s.handleAuthPolicy(rec, req)
s.handleRenameDevice(rec, req, store.Session{EmbyUserID: "user-1"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
var policy authPolicyResponse
if err := json.Unmarshal(rec.Body.Bytes(), &policy); err != nil {
t.Fatalf("decode policy: %v", err)
}
func TestRecommendationOnboardingCandidatesMixMoviesAndSeries(t *testing.T) {
items := []recommend.Item{
{ID: "m1", Name: "Movie 1", Type: "Movie", CommunityRating: 9, Genres: []string{"Drama"}},
{ID: "m2", Name: "Movie 2", Type: "Movie", CommunityRating: 8.9, Genres: []string{"Comedy"}},
{ID: "s1", Name: "Series 1", Type: "Series", CommunityRating: 8.8, Genres: []string{"Drama"}},
{ID: "s2", Name: "Series 2", Type: "Series", CommunityRating: 8.7, Genres: []string{"Comedy"}},
{ID: "e1", Name: "Episode", Type: "Episode", CommunityRating: 10},
}
if policy.MaxClientsPerUser != 4 {
t.Fatalf("max clients = %d, want 4", policy.MaxClientsPerUser)
got := recommendationOnboardingCandidates(items, 4)
if len(got) != 4 {
t.Fatalf("candidate count = %d, want 4", len(got))
}
for i, kind := range []string{"Movie", "Series", "Movie", "Series"} {
if got[i].Type != kind {
t.Fatalf("candidate %d type = %q, want %q", i, got[i].Type, kind)
}
}
}
func TestEpisodeCodeFormatsPlaybackMetadata(t *testing.T) {
item := emby.Summary{Type: "Episode", ParentIndexNumber: 2, IndexNumber: 4}
if got := episodeCode(item); got != "S02E04" {
t.Fatalf("episode code = %q, want S02E04", got)
}
if got := episodeCode(emby.Summary{Type: "Movie", IndexNumber: 4}); got != "" {
t.Fatalf("movie episode code = %q, want empty", got)
}
}
@@ -95,6 +121,22 @@ func TestSonarrScheduleRequiresCapableClient(t *testing.T) {
}
}
func TestRadarrScheduleRequiresMovieScheduleClient(t *testing.T) {
tests := map[string]bool{
"": false,
"0.1.78": false,
"0.1.79": true,
"0.2.0": true,
}
for version, want := range tests {
req := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
req.Header.Set("X-Memby-Version", version)
if got := supportsRadarrSchedule(req); got != want {
t.Errorf("supportsRadarrSchedule(%q) = %v, want %v", version, got, want)
}
}
}
func TestPlaybackHintAvoidsAnUpstreamItemLookup(t *testing.T) {
req := httptest.NewRequest(
http.MethodGet,
+95 -55
View File
@@ -2,9 +2,9 @@ package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
@@ -19,22 +19,22 @@ type loginRequest struct {
}
type loginResponse struct {
Token string `json:"token"`
UserID string `json:"userId"`
Username string `json:"username"`
ServerID string `json:"serverId"`
ActiveClients int `json:"activeClients,omitempty"`
MaxClientsPerUser int `json:"maxClientsPerUser"`
Token string `json:"token"`
UserID string `json:"userId"`
Username string `json:"username"`
ServerID string `json:"serverId"`
}
type authPolicyResponse struct {
MaxClientsPerUser int `json:"maxClientsPerUser"`
type deviceSessionResponse struct {
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
ClientVersion string `json:"clientVersion,omitempty"`
LastSeenAt time.Time `json:"lastSeenAt"`
Current bool `json:"current"`
}
func (s *Server) handleAuthPolicy(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, authPolicyResponse{
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
})
type renameDeviceRequest struct {
DeviceName string `json:"deviceName"`
}
// handleLogin exchanges Emby credentials for a gateway token.
@@ -86,42 +86,21 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
sess := store.Session{
TokenHash: hashToken(token),
EmbyUserID: auth.User.ID,
EmbyToken: auth.AccessToken,
Username: auth.User.Name,
ServerID: auth.ServerID,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
ClientVersion: clientVersion(r),
ClientProtocol: clientProtocol(r),
TokenHash: hashToken(token),
EmbyUserID: auth.User.ID,
EmbyToken: auth.AccessToken,
Username: auth.User.Name,
ServerID: auth.ServerID,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
ClientVersion: clientVersion(r),
ClientProtocol: clientProtocol(r),
ClientCapabilities: clientCapabilities(r),
}
if sess.Username == "" {
sess.Username = req.Username
}
replacedHash, activeClients, err := s.store.CreateSession(
r.Context(), sess, s.cfg.MaxClientsPerUser,
)
if errors.Is(err, store.ErrDeviceLimit) {
if revokeErr := s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
}); revokeErr != nil {
s.log.Warn("could not retire refused emby session", "error", revokeErr)
}
s.log.Warn("device allowance reached",
"username", sess.Username,
"active_clients", activeClients,
"max_clients", s.cfg.MaxClientsPerUser,
)
writeJSON(w, http.StatusConflict, map[string]any{
"error": "device_limit_reached",
"message": "This account has reached its Memby device allowance.",
"activeClients": activeClients,
"maxClientsPerUser": s.cfg.MaxClientsPerUser,
})
return
}
replacedHash, err := s.store.CreateSession(r.Context(), sess)
if err != nil {
_ = s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
@@ -140,12 +119,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, loginResponse{
Token: token,
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
ActiveClients: activeClients,
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
Token: token, UserID: sess.EmbyUserID, Username: sess.Username, ServerID: sess.ServerID,
})
}
@@ -161,9 +135,75 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store
// handleSession lets the TV confirm a stored token is still good before rendering.
func (s *Server) handleSession(w http.ResponseWriter, _ *http.Request, sess store.Session) {
writeJSON(w, http.StatusOK, loginResponse{
UserID: sess.EmbyUserID,
Username: sess.Username,
ServerID: sess.ServerID,
MaxClientsPerUser: s.cfg.MaxClientsPerUser,
UserID: sess.EmbyUserID, Username: sess.Username, ServerID: sess.ServerID,
})
}
func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request, current store.Session) {
sessions, err := s.store.SessionsForUser(r.Context(), current.EmbyUserID)
if err != nil {
s.log.Error("device list failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not list devices")
return
}
devices := make([]deviceSessionResponse, 0, len(sessions))
for _, sess := range sessions {
devices = append(devices, deviceSessionResponse{
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
ClientVersion: sess.ClientVersion, LastSeenAt: sess.LastSeenAt,
Current: string(sess.TokenHash) == string(current.TokenHash),
})
}
writeJSON(w, http.StatusOK, map[string]any{"devices": devices})
}
func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request, current store.Session) {
deviceID := strings.TrimSpace(r.PathValue("deviceID"))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "device id is required")
return
}
if deviceID == current.DeviceID {
writeError(w, http.StatusBadRequest, "sign out to remove the current device")
return
}
tokenHash, err := s.store.DeleteUserDevice(r.Context(), current.EmbyUserID, deviceID)
if err == store.ErrNotFound {
writeError(w, http.StatusNotFound, "device not found")
return
}
if err != nil {
s.log.Error("device revoke failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not remove device")
return
}
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(tokenHash)))
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRenameDevice(w http.ResponseWriter, r *http.Request, current store.Session) {
deviceID := strings.TrimSpace(r.PathValue("deviceID"))
var req renameDeviceRequest
if deviceID == "" || json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req) != nil {
writeError(w, http.StatusBadRequest, "device id and name are required")
return
}
req.DeviceName = strings.TrimSpace(req.DeviceName)
if req.DeviceName == "" {
writeError(w, http.StatusBadRequest, "device name is required")
return
}
if len([]rune(req.DeviceName)) > 80 {
writeError(w, http.StatusBadRequest, "device name is too long")
return
}
if err := s.store.RenameUserDevice(r.Context(), current.EmbyUserID, deviceID, req.DeviceName); err == store.ErrNotFound {
writeError(w, http.StatusNotFound, "device not found")
return
} else if err != nil {
s.log.Error("device rename failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not rename device")
return
}
w.WriteHeader(http.StatusNoContent)
}
+233
View File
@@ -0,0 +1,233 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"slices"
"strings"
"github.com/ponzischeme89/memby/server/internal/store"
)
const (
featureSchemaVersion = 1
featureSonarrPreroll = "sonarr_preroll"
featureAutomaticMyShows = "automatic_my_shows"
featureMyShowsNotification = "my_shows_notifications"
featureHEVCDirectPlay = "hevc_direct_play"
)
type featureDefinition struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
Area string `json:"area"`
DefaultEnabled bool `json:"defaultEnabled"`
MinimumProtocol int `json:"minimumProtocol"`
Capability string `json:"capability"`
Recovery string `json:"recovery"`
}
var featureCatalogue = []featureDefinition{
{
Key: featureSonarrPreroll, Name: "Sonarr upcoming preroll", Area: "Playback",
Description: "Show the fan-art calendar before a fresh episode starts.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "sonarr_preroll_v1",
Recovery: "Takes effect the next time a title is opened.",
},
{
Key: featureAutomaticMyShows, Name: "Automatic My Shows", Area: "My Shows",
Description: "Follow a continuing Sonarr show after half an episode is watched.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "auto_my_shows_v1",
Recovery: "Server-enforced; takes effect on the next playback report.",
},
{
Key: featureMyShowsNotification, Name: "Automatic follow notification", Area: "Notifications",
Description: "Notify a viewer when a continuing show is automatically followed.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "auto_my_shows_v1",
Recovery: "Server-enforced; disabling it never removes a saved show.",
},
{
Key: featureHEVCDirectPlay, Name: "HEVC direct play", Area: "Playback",
Description: "Allow capable TVs to direct-play H.265/HEVC instead of requesting H.264.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "video_hevc_decode",
Recovery: "Server-enforced; takes effect the next time playback starts or refreshes.",
},
}
type evaluatedFeature struct {
featureDefinition
Enabled bool `json:"enabled"`
Source string `json:"source"`
Compatible bool `json:"compatible"`
}
type featureResponse struct {
SchemaVersion int `json:"schemaVersion"`
Revision int64 `json:"revision"`
SafeMode bool `json:"safeMode"`
UpdatedAt any `json:"updatedAt,omitempty"`
CanRollback bool `json:"canRollback"`
Features []evaluatedFeature `json:"features"`
}
func knownFeature(key string) (featureDefinition, bool) {
for _, definition := range featureCatalogue {
if definition.Key == key {
return definition, true
}
}
return featureDefinition{}, false
}
func evaluateFeature(policy store.FeaturePolicy, definition featureDefinition, protocol int) evaluatedFeature {
enabled, source := definition.DefaultEnabled, "default"
if override, ok := policy.Overrides[definition.Key]; ok {
enabled, source = override, "override"
}
if policy.SafeMode {
enabled, source = false, "safe_mode"
}
compatible := protocol >= definition.MinimumProtocol
if !compatible {
enabled, source = false, "incompatible_client"
}
return evaluatedFeature{featureDefinition: definition, Enabled: enabled, Source: source, Compatible: compatible}
}
func (s *Server) currentFeaturePolicy(ctx context.Context) store.FeaturePolicy {
if s.store == nil {
return store.DefaultFeaturePolicy()
}
policy, err := s.store.FeaturePolicy(ctx)
if err != nil {
s.log.Warn("feature policy unavailable; using safe defaults", "error", err)
return store.DefaultFeaturePolicy()
}
return policy
}
func (s *Server) featureEnabled(ctx context.Context, key string) bool {
definition, ok := knownFeature(key)
if !ok {
return false
}
return evaluateFeature(s.currentFeaturePolicy(ctx), definition, membyProtocolVersion).Enabled
}
func featurePayload(policy store.FeaturePolicy, protocol int, capabilities ...[]string) featureResponse {
features := make([]evaluatedFeature, 0, len(featureCatalogue))
for _, definition := range featureCatalogue {
evaluated := evaluateFeature(policy, definition, protocol)
if len(capabilities) > 0 && definition.Capability != "" &&
!slices.Contains(capabilities[0], definition.Capability) {
evaluated.Enabled = false
evaluated.Compatible = false
evaluated.Source = "missing_capability"
}
features = append(features, evaluated)
}
return featureResponse{
SchemaVersion: featureSchemaVersion, Revision: policy.Revision,
SafeMode: policy.SafeMode, UpdatedAt: policy.UpdatedAt,
CanRollback: policy.Previous != nil, Features: features,
}
}
func featureMap(policy store.FeaturePolicy, protocol int, capabilities []string) map[string]bool {
result := make(map[string]bool, len(featureCatalogue))
for _, feature := range featurePayload(policy, protocol, capabilities).Features {
result[feature.Key] = feature.Enabled
}
return result
}
func (s *Server) handleFeatures(w http.ResponseWriter, r *http.Request, _ store.Session) {
writeJSON(w, http.StatusOK, featurePayload(
s.currentFeaturePolicy(r.Context()), clientProtocolNumber(r), clientCapabilities(r),
))
}
type featurePolicyRequest struct {
Action string `json:"action"`
ExpectedRevision int64 `json:"expectedRevision"`
Overrides map[string]bool `json:"overrides"`
}
func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request) {
var req featurePolicyRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
current := s.currentFeaturePolicy(r.Context())
if req.ExpectedRevision != current.Revision {
writeError(w, http.StatusConflict, "feature flags changed in another admin session; refresh before saving")
return
}
next := store.FeaturePolicy{Overrides: map[string]bool{}, SafeMode: current.SafeMode}
switch strings.TrimSpace(req.Action) {
case "save":
for key, enabled := range req.Overrides {
if _, ok := knownFeature(key); !ok {
writeError(w, http.StatusBadRequest, "unknown feature flag: "+key)
return
}
next.Overrides[key] = enabled
}
case "safe-mode":
next.Overrides = current.Overrides
next.SafeMode = true
case "leave-safe-mode":
next.Overrides = current.Overrides
next.SafeMode = false
case "reset":
next.SafeMode = false
case "rollback":
if current.Previous == nil {
writeError(w, http.StatusConflict, "there is no previous feature revision to restore")
return
}
next.Overrides = current.Previous.Overrides
next.SafeMode = current.Previous.SafeMode
default:
writeError(w, http.StatusBadRequest, "unknown feature policy action")
return
}
stored, err := s.store.SetFeaturePolicy(r.Context(), next, req.ExpectedRevision)
if err != nil {
if errors.Is(err, store.ErrFeaturePolicyConflict) {
writeError(w, http.StatusConflict, "feature flags changed in another admin session; refresh before saving")
return
}
s.log.Error("feature policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save feature flags")
return
}
s.log.Info("feature policy changed", "action", req.Action, "revision", stored.Revision,
"safe_mode", stored.SafeMode, "overrides", len(stored.Overrides))
writeJSON(w, http.StatusOK, featurePayload(stored, membyProtocolVersion))
}
func parseCapabilities(raw string) []string {
seen := map[string]bool{}
values := []string{}
for _, value := range strings.Split(raw, ",") {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || len(value) > 64 || seen[value] {
continue
}
seen[value] = true
values = append(values, value)
}
slices.Sort(values)
return values
}
func clientCapabilities(r *http.Request) []string {
return parseCapabilities(r.Header.Get("X-Memby-Capabilities"))
}
+69
View File
@@ -0,0 +1,69 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestFeatureEvaluationSupportsDefaultsOverridesAndSafeMode(t *testing.T) {
definition, ok := knownFeature(featureSonarrPreroll)
if !ok {
t.Fatal("preroll is not registered")
}
if got := evaluateFeature(store.DefaultFeaturePolicy(), definition, 1); !got.Enabled || got.Source != "default" {
t.Fatalf("default evaluation = %+v", got)
}
off := store.FeaturePolicy{Overrides: map[string]bool{featureSonarrPreroll: false}}
if got := evaluateFeature(off, definition, 1); got.Enabled || got.Source != "override" {
t.Fatalf("off evaluation = %+v", got)
}
safe := store.FeaturePolicy{Overrides: map[string]bool{featureSonarrPreroll: true}, SafeMode: true}
if got := evaluateFeature(safe, definition, 1); got.Enabled || got.Source != "safe_mode" {
t.Fatalf("safe-mode evaluation = %+v", got)
}
}
func TestFeatureEvaluationFailsClosedForAnIncompatibleClient(t *testing.T) {
definition := featureDefinition{Key: "future", DefaultEnabled: true, MinimumProtocol: 2}
got := evaluateFeature(store.DefaultFeaturePolicy(), definition, 1)
if got.Enabled || got.Compatible || got.Source != "incompatible_client" {
t.Fatalf("incompatible evaluation = %+v", got)
}
}
func TestHEVCDirectPlayIsVisibleAndServerControllable(t *testing.T) {
definition, ok := knownFeature(featureHEVCDirectPlay)
if !ok || definition.Area != "Playback" || definition.Capability != "video_hevc_decode" {
t.Fatalf("HEVC feature definition = %+v, found=%v", definition, ok)
}
disabled := store.FeaturePolicy{Overrides: map[string]bool{featureHEVCDirectPlay: false}}
if got := evaluateFeature(disabled, definition, 1); got.Enabled {
t.Fatalf("HEVC override = %+v", got)
}
}
func TestClientCapabilitiesAreNormalizedAndBounded(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
req.Header.Set("X-Memby-Capabilities", " Sonarr_Preroll_V1,server_features_v1,sonarr_preroll_v1,"+
"this-capability-name-is-deliberately-longer-than-sixty-four-characters-and-is-rejected")
got := clientCapabilities(req)
if len(got) != 2 || got[0] != "server_features_v1" || got[1] != "sonarr_preroll_v1" {
t.Fatalf("capabilities = %#v", got)
}
}
func TestFeatureAdminRejectsUnknownFlagsBeforeWriting(t *testing.T) {
server := testServer(config.Config{})
req := httptest.NewRequest(http.MethodPost, "/admin/api/features",
strings.NewReader(`{"action":"save","expectedRevision":0,"overrides":{"made_up":true}}`))
rec := httptest.NewRecorder()
server.handleAdminFeaturePolicy(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
+31
View File
@@ -0,0 +1,31 @@
package api
import (
"testing"
"github.com/ponzischeme89/memby/server/internal/sonarr"
)
func TestIsSeasonFinale(t *testing.T) {
episodes := []sonarr.Episode{
{SeasonNumber: 2, EpisodeNumber: 1},
{SeasonNumber: 2, EpisodeNumber: 8},
{SeasonNumber: 2, EpisodeNumber: 10},
{SeasonNumber: 3, EpisodeNumber: 1},
}
if !isSeasonFinale(2, 10, episodes) {
t.Fatal("last numbered episode in the season should be a finale")
}
if isSeasonFinale(2, 8, episodes) {
t.Fatal("an episode followed by another episode in its season is not a finale")
}
if isSeasonFinale(0, 10, episodes) {
t.Fatal("specials must not be announced as season finales")
}
}
func TestProviderIDIsCaseInsensitive(t *testing.T) {
if got := providerID(map[string]string{"Tvdb": "123"}, "tvdb"); got != "123" {
t.Fatalf("provider id = %q, want 123", got)
}
}
+154 -47
View File
@@ -6,6 +6,7 @@ import (
"math/rand/v2"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
@@ -22,8 +23,8 @@ import (
// single biggest lever on home-screen latency, so keep these tight.
const (
fieldsContinue = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio"
fieldsRow = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio"
fieldsRow = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio,CollectionName"
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
rowImageTypes = "Backdrop,Primary,Logo"
@@ -56,7 +57,13 @@ type homeResponse struct {
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
limit := queryInt(r, "limit", 24, 100)
key := cache.UserKey(sess.EmbyUserID, "home:"+itoa(limit))
sonarrSchedule := s.sonarr != nil && supportsSonarrSchedule(r)
radarrSchedule := s.radarr != nil && supportsRadarrSchedule(r)
key := cache.UserKey(
sess.EmbyUserID,
"home:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
":r"+strconv.FormatBool(radarrSchedule)+":d"+sess.DeviceID,
)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
@@ -70,7 +77,8 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
failures int
out homeResponse
sonarrRow *recommend.Row
forYouRow *recommend.Row
radarrRow *recommend.Row
forYouRows []recommend.Row
forYouRowStale bool
wg sync.WaitGroup
)
@@ -114,15 +122,22 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
}, fieldsContinue))
})
run(&out.LatestMovies, func() (*emby.ItemsResult, error) {
newReleaseDays := s.weightedConfig().NewReleaseDays
if newReleaseDays < 1 {
newReleaseDays = recommend.DefaultWeightedConfig().NewReleaseDays
}
return s.emby.Items(ctx, cred, rowParams(url.Values{
"IncludeItemTypes": {"Movie"},
"Recursive": {"true"},
"SortBy": {"DateCreated"},
"SortOrder": {"Descending"},
"Limit": {itoa(limit)},
// Eligibility precedes personalization: this row means a real recent
// release, not merely an old film imported into the library yesterday.
"MinPremiereDate": {time.Now().AddDate(0, 0, -newReleaseDays).UTC().Format(time.RFC3339)},
"SortBy": {"PremiereDate"},
"SortOrder": {"Descending"},
"Limit": {itoa(limit)},
}, fieldsRow))
})
if s.sonarr != nil && supportsSonarrSchedule(r) {
if sonarrSchedule {
wg.Add(1)
go func() {
defer wg.Done()
@@ -136,6 +151,20 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
mu.Unlock()
}()
}
if radarrSchedule {
wg.Add(1)
go func() {
defer wg.Done()
row, err := s.radarrUpcomingMoviesRow(ctx)
if err != nil {
s.log.Warn("radarr calendar row failed", "error", err)
return
}
mu.Lock()
radarrRow = row
mu.Unlock()
}()
}
if s.forYou != nil {
// The prepared pool is one indexed PostgreSQL read. It runs beside the Emby
// calls and deliberately has no live-engine fallback, so Home can never inherit
@@ -156,24 +185,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
if !hit || len(prepared) == 0 {
return
}
rowIndex := -1
for i := range prepared {
if prepared[i].ID == "for-you:picks" {
rowIndex = i
break
}
}
if rowIndex < 0 {
homeRows := preparedHomeForYouRows(prepared, window)
if len(homeRows) == 0 {
return
}
row := prepared[rowIndex]
row.ID = "for-you:home:" + window.ID
row.Title = window.Title
if len(row.Items) > 12 {
row.Items = row.Items[:12]
}
mu.Lock()
forYouRow = &row
forYouRows = homeRows
forYouRowStale = stale
mu.Unlock()
}()
@@ -196,9 +213,9 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
s.refreshRecommendationsInBackground(sess)
}
rows := baseRows(out)
nearContinue := make([]recommend.Row, 0, 2)
if forYouRow != nil {
nearContinue = append(nearContinue, *forYouRow)
nearContinue := make([]recommend.Row, 0, len(forYouRows)+2)
if len(forYouRows) > 0 {
nearContinue = append(nearContinue, forYouRows...)
if forYouRowStale {
s.forYou.MarkDirty(context.WithoutCancel(ctx), sess)
s.forYou.RefreshAsync(sess, false)
@@ -207,12 +224,28 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
if sonarrRow != nil {
nearContinue = append(nearContinue, *sonarrRow)
}
if radarrRow != nil {
nearContinue = append(nearContinue, *radarrRow)
}
if len(nearContinue) > 0 {
// Personalised discovery and today's schedule are most useful immediately
// Personalised discovery and the upcoming schedule are most useful immediately
// after Continue Watching, before the broader library collections.
rows = append(rows[:1], append(nearContinue, rows[1:]...)...)
}
out.Rows = append(rows, recommendations...)
out.Rows = s.filterRecommendationPermissions(ctx, sess, out.Rows)
if stats, err := s.store.UserRowStats(
ctx,
sess.EmbyUserID,
time.Now().Add(-45*24*time.Hour),
); err != nil {
s.log.Warn("home row preferences unavailable", "user", sess.EmbyUserID, "error", err)
} else {
out.Rows = personalizeHomeRows(out.Rows, stats)
}
out.Rows = s.personalizeTitles(ctx, sess, out.Rows)
out.Rows = personalizeRowsByTitleScores(selectPersonalizedRows(out.Rows))
out.Rows = deduplicateRows(out.Rows)
body, err := json.Marshal(out)
if err != nil {
@@ -230,6 +263,81 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
writeRaw(w, http.StatusOK, body)
}
// personalizeHomeRows applies a deliberately conservative engagement nudge. New and
// lightly sampled rows keep their authored position; only shelves repeatedly shown and
// ignored lose ground. Continue Watching remains the stable first landmark, while a
// selection or meaningful dwell quickly earns a shelf its position back.
func personalizeHomeRows(rows []recommend.Row, stats []store.RowStat) []recommend.Row {
if len(rows) < 2 || len(stats) == 0 {
return rows
}
byID := make(map[string]store.RowStat, len(stats))
for _, stat := range stats {
byID[stat.RowID] = stat
}
type rankedRow struct {
row recommend.Row
position int
score float64
}
ranked := make([]rankedRow, 0, len(rows))
for position, row := range rows {
score := 1.0
if stat, ok := byID[row.ID]; ok && stat.Impressions >= 5 {
// Two neutral pseudo-impressions prevent a tiny sample from producing an
// extreme score. Dwell is capped by the ingestion endpoint.
engagement := float64(stat.Selects)*6 +
float64(stat.Focuses) +
float64(stat.DwellMs)/30_000
score = (engagement + 2) / (float64(stat.Impressions) + 2)
}
switch row.ID {
case "continue":
score = 1_000
case "next-up":
score += 0.35
}
ranked = append(ranked, rankedRow{row: row, position: position, score: score})
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].score != ranked[j].score {
return ranked[i].score > ranked[j].score
}
return ranked[i].position < ranked[j].position
})
out := make([]recommend.Row, 0, len(ranked))
for _, entry := range ranked {
out = append(out, entry.row)
}
return out
}
// preparedHomeForYouRows promotes the specific abandoned-show shelf as well as the
// time-aware general picks. Other For You shelves remain in the dedicated destination.
func preparedHomeForYouRows(
prepared []recommend.Row,
window homeForYouWindow,
) []recommend.Row {
rows := make([]recommend.Row, 0, 2)
for _, source := range prepared {
row := source
switch row.ID {
case "for-you:pick-up":
row.Title = "Pick this show up again"
case "for-you:picks":
row.ID = "for-you:home:" + window.ID
row.Title = window.Title
default:
continue
}
if len(row.Items) > 12 {
row.Items = row.Items[:12]
}
rows = append(rows, row)
}
return rows
}
// Older clients render unknown rows but do not understand MembyPlayable=false, so they
// could try to send a synthetic Sonarr id to Emby. The feature ships with 0.1.54.
func supportsSonarrSchedule(r *http.Request) bool {
@@ -237,6 +345,12 @@ func supportsSonarrSchedule(r *http.Request) bool {
return version != "" && appupdate.CompareVersions(version, "0.1.54") >= 0
}
// Radarr movie schedules need the movie-specific row presentation introduced in 0.1.79.
func supportsRadarrSchedule(r *http.Request) bool {
version := clientVersion(r)
return version != "" && appupdate.CompareVersions(version, "0.1.79") >= 0
}
// handleScreensaver serves the backdrop pool. The pool is cached and shuffled per
// request, so the Dream still looks random without re-querying Emby every few seconds.
func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess store.Session) {
@@ -286,7 +400,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
return
}
limit := queryInt(r, "limit", 40, 100)
key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term)
key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term+":d"+sess.DeviceID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
@@ -294,27 +408,20 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
return
}
// The imported library answers search from Postgres, which is the difference
// between "instant" and "one round trip to Emby per keystroke". An empty result
// falls through to Emby, so search still works before the first import completes.
items, err := s.store.SearchLibrary(ctx, term, limit)
// Search always asks Emby with the signed-in user's credentials. The imported
// household catalogue may contain titles hidden by library permissions or parental
// controls and therefore cannot be an eligibility authority.
result, err := s.emby.Items(ctx, credentials(sess), rowParams(url.Values{
"SearchTerm": {term},
"IncludeItemTypes": {"Movie,Series,Episode"},
"Recursive": {"true"},
"Limit": {itoa(limit)},
}, fieldsRow))
if err != nil {
s.log.Warn("library search failed; falling back to emby", "error", err)
items = nil
}
if len(items) == 0 {
result, err := s.emby.Items(ctx, credentials(sess), rowParams(url.Values{
"SearchTerm": {term},
"IncludeItemTypes": {"Movie,Series,Episode"},
"Recursive": {"true"},
"Limit": {itoa(limit)},
}, fieldsRow))
if err != nil {
s.writeUpstreamError(w, err, "search failed")
return
}
items = result.Items
s.writeUpstreamError(w, err, "search failed")
return
}
items := s.personalizeSearch(ctx, sess, term, result.Items, limit)
body, err := json.Marshal(map[string]any{"items": nonNil(items)})
if err != nil {
+130
View File
@@ -0,0 +1,130 @@
package api
import (
"encoding/json"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestPersonalizeHomeRowsGraduallyDemotesIgnoredRows(t *testing.T) {
rows := []recommend.Row{
{ID: "continue"},
{ID: "ignored"},
{ID: "new-row"},
{ID: "engaged"},
}
stats := []store.RowStat{
{RowID: "ignored", Impressions: 20},
// Under the five-impression confidence floor, new-row keeps its authored score.
{RowID: "new-row", Impressions: 4},
{RowID: "engaged", Impressions: 20, Focuses: 8, Selects: 3, DwellMs: 120_000},
}
got := personalizeHomeRows(rows, stats)
want := []string{"continue", "engaged", "new-row", "ignored"}
for i, id := range want {
if got[i].ID != id {
t.Fatalf("row %d = %q, want %q; rows=%+v", i, got[i].ID, id, got)
}
}
}
func TestDifferentHistoriesChangeRowAndPosterOrdering(t *testing.T) {
now := time.Date(2026, 7, 31, 20, 0, 0, 0, time.UTC)
item := func(id, name, genre string) recommend.Item {
raw, _ := json.Marshal(map[string]any{
"Id": id, "Name": name, "Type": "Movie", "Genres": []string{genre},
"CommunityRating": 7.0,
})
return recommend.Item{
ID: id, Name: name, Type: "Movie", Genres: []string{genre},
CommunityRating: 7, Raw: raw,
}
}
dramaA, dramaB := item("drama-a", "Drama A", "Drama"), item("drama-b", "Drama B", "Drama")
comedyA, comedyB := item("comedy-a", "Comedy A", "Comedy"), item("comedy-b", "Comedy B", "Comedy")
profileFor := func(genre string) recommend.WeightedProfile {
return recommend.BuildWeightedProfile([]recommend.ViewingEvidence{
{Item: item("seen-1", "Seen 1", genre), Completion: 1, OccurredAt: now.Add(-time.Hour)},
{Item: item("seen-2", "Seen 2", genre), Completion: 1, OccurredAt: now.Add(-2 * time.Hour)},
}, now, time.UTC)
}
pageFor := func(profile recommend.WeightedProfile) []recommend.Row {
row := func(id string, items []recommend.Item) recommend.Row {
ranked := recommend.WeightedRank(
profile, items, nil, recommend.RankIntent{Now: now},
recommend.DefaultWeightedConfig(), 10,
)
raws := make([]json.RawMessage, 0, len(ranked))
for _, value := range ranked {
raws = append(raws, recommend.EnrichRankedItem(value))
}
return recommend.Row{ID: id, Items: raws}
}
return personalizeRowsByTitleScores([]recommend.Row{
row("drama-row", []recommend.Item{dramaA, dramaB}),
row("comedy-row", []recommend.Item{comedyA, comedyB}),
row("mixed-row", []recommend.Item{comedyA, dramaA}),
})
}
dramaPage := pageFor(profileFor("Drama"))
comedyPage := pageFor(profileFor("Comedy"))
if dramaPage[0].ID != "drama-row" || comedyPage[0].ID != "comedy-row" {
t.Fatalf("row orders did not personalize: drama=%s comedy=%s",
dramaPage[0].ID, comedyPage[0].ID)
}
firstMixedID := func(rows []recommend.Row) string {
for _, row := range rows {
if row.ID == "mixed-row" {
return recommend.Decode(row.Items)[0].ID
}
}
return ""
}
if firstMixedID(dramaPage) != "drama-a" || firstMixedID(comedyPage) != "comedy-a" {
t.Fatalf("poster orders did not personalize: drama=%s comedy=%s",
firstMixedID(dramaPage), firstMixedID(comedyPage))
}
}
func TestPersonalizeHomeRowsPreservesColdStartDefaults(t *testing.T) {
rows := []recommend.Row{{ID: "continue"}, {ID: "next-up"}, {ID: "latest"}}
got := personalizeHomeRows(rows, nil)
for i := range rows {
if got[i].ID != rows[i].ID {
t.Fatalf("cold-start order changed: %+v", got)
}
}
}
func TestPreparedHomeRowsPromotesAbandonedShowsAndTimeAwarePicks(t *testing.T) {
prepared := []recommend.Row{
{
ID: "for-you:pick-up", Title: "old pickup title", Kind: "for-you",
Items: []json.RawMessage{json.RawMessage(`{"Id":"abandoned"}`)},
},
{
ID: "for-you:picks", Title: "old picks title", Kind: "for-you",
Items: []json.RawMessage{json.RawMessage(`{"Id":"pick"}`)},
},
{ID: "for-you:genre:drama", Title: "Drama", Kind: "for-you"},
}
rows := preparedHomeForYouRows(prepared, homeForYouWindow{
ID: "afternoon", Title: "An hour for your afternoon", Minutes: 60,
})
if len(rows) != 2 {
t.Fatalf("home For You rows = %+v", rows)
}
if rows[0].ID != "for-you:pick-up" ||
rows[0].Title != "Pick this show up again" {
t.Fatalf("pickup row = %+v", rows[0])
}
if rows[1].ID != "for-you:home:afternoon" ||
rows[1].Title != "An hour for your afternoon" {
t.Fatalf("time-aware row = %+v", rows[1])
}
}
+49
View File
@@ -12,6 +12,7 @@ import (
"strings"
"syscall"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -40,6 +41,10 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
s.handleSonarrImage(w, r, itemID, imageType)
return
}
if strings.HasPrefix(itemID, "radarr:") {
s.handleRadarrImage(w, r, itemID, imageType)
return
}
params := url.Values{}
for _, key := range []string{"tag", "maxWidth", "maxHeight", "quality"} {
@@ -78,6 +83,50 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
"source", "emby", "item_id", itemID, "image_type", imageType)
}
func (s *Server) handleRadarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
if s.radarr == nil {
writeError(w, http.StatusNotFound, "unknown image")
return
}
parts := strings.Split(itemID, ":")
if len(parts) != 2 {
writeError(w, http.StatusNotFound, "unknown image")
return
}
movieID, err := strconv.Atoi(parts[1])
if err != nil || movieID <= 0 {
writeError(w, http.StatusNotFound, "unknown image")
return
}
coverType := map[string]string{"Primary": "poster", "Backdrop": "fanart"}[imageType]
if coverType == "" {
writeError(w, http.StatusNotFound, "unknown image")
return
}
resp, err := s.radarr.MediaCover(r.Context(), movieID, coverType)
if err != nil {
var apiErr *radarr.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
writeError(w, http.StatusNotFound, "image not found")
return
}
s.log.Warn("radarr image failed", "movie_id", movieID, "type", coverType, "error", err)
writeError(w, http.StatusBadGateway, "could not load the image")
return
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
w.Header().Set("Content-Length", cl)
}
w.Header().Set("Cache-Control", "private, max-age=3600")
w.WriteHeader(http.StatusOK)
copyImage(w, r, resp.Body, s.log,
"source", "radarr", "item_id", itemID, "image_type", imageType)
}
func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemID, imageType string) {
if s.sonarr == nil {
writeError(w, http.StatusNotFound, "unknown image")
+105
View File
@@ -0,0 +1,105 @@
<!doctype html>
<html lang="en-NZ">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="robots" content="noindex,nofollow,noarchive,nosnippet,noimageindex">
<meta name="googlebot" content="noindex,nofollow,noarchive,nosnippet,noimageindex">
<meta name="bingbot" content="noindex,nofollow,noarchive,nosnippet,noimageindex">
<meta name="referrer" content="no-referrer">
<title>{{if .Authenticated}}Install Memby{{else}}Sign in{{end}}</title>
<style>
:root { color-scheme: dark; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; }
* { box-sizing: border-box; }
body {
min-height: 100vh; margin: 0; padding: 32px 20px; display: grid; place-items: center;
color: #f2f5f7; background:
radial-gradient(circle at 50% 5%, rgba(82,181,75,.18), transparent 42%),
#0b0e11;
}
main {
width: min(680px, 100%); padding: clamp(28px, 6vw, 56px);
border: 1px solid rgba(255,255,255,.11); border-radius: 22px;
background: rgba(20,27,33,.94); box-shadow: 0 24px 80px rgba(0,0,0,.42);
}
.mark {
width: 68px; height: 68px; display: grid; place-items: center; margin-bottom: 26px;
border-radius: 18px; background: #52b54b; color: #08230a;
font-size: 34px; font-weight: 900;
}
.subtle-mark { width: 42px; height: 42px; margin-bottom: 20px; border-radius: 12px; font-size: 21px; }
.login-title { font-size: 26px; letter-spacing: -.025em; }
.login-intro { margin-bottom: 22px; font-size: 15px; }
h1 { margin: 0 0 10px; font-size: clamp(32px, 7vw, 48px); letter-spacing: -.04em; }
.intro { margin: 0 0 28px; color: #b6c0c8; font-size: 18px; line-height: 1.55; }
.download {
display: block; padding: 17px 24px; border-radius: 12px; text-align: center;
color: #08230a; background: #63c85b; text-decoration: none;
font-size: 19px; font-weight: 750;
}
.download:hover, .download:focus { background: #7bd973; outline: 3px solid #d7ffd4; outline-offset: 4px; }
form { display: grid; gap: 17px; }
label { display: grid; gap: 7px; color: #c8d0d6; font-size: 15px; font-weight: 650; }
input {
width: 100%; min-height: 54px; padding: 12px 14px; border: 1px solid #52606b;
border-radius: 10px; color: #f2f5f7; background: #0d1217; font: inherit;
}
input:focus { outline: 3px solid #86dc80; outline-offset: 2px; border-color: transparent; }
button {
min-height: 54px; padding: 13px 20px; border: 0; border-radius: 10px;
color: #08230a; background: #63c85b; font: inherit; font-size: 17px; font-weight: 750;
cursor: pointer;
}
button:hover, button:focus { background: #7bd973; outline: 3px solid #d7ffd4; outline-offset: 3px; }
.error { margin: 0; padding: 12px 14px; border-radius: 9px; color: #ffd4d4; background: #3b2020; }
.logout { margin-top: 24px; }
.logout button { min-height: 42px; color: #c9d1d7; background: #26313a; font-size: 14px; }
.meta { margin: 13px 0 0; color: #8f9ba5; text-align: center; font-size: 14px; }
ol { margin: 34px 0 0; padding-left: 24px; color: #c8d0d6; line-height: 1.65; }
li + li { margin-top: 10px; }
.notes { margin-top: 26px; padding-top: 22px; border-top: 1px solid rgba(255,255,255,.1); color: #9faab3; }
.unavailable { padding: 18px; border-radius: 12px; background: #33251b; color: #ffd7b0; line-height: 1.5; }
code { color: #d9ffd6; }
</style>
</head>
<body>
<main>
{{if not .Authenticated}}
<div class="mark subtle-mark">M</div>
<h1 class="login-title">Sign in</h1>
<p class="intro login-intro">Login required to continue.</p>
<form method="post" action="/install/login">
<input name="next" type="hidden" value="{{.LoginNext}}">
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
<label>
Username
<input name="username" type="text" autocomplete="username" autocapitalize="none" required autofocus>
</label>
<label>
Password
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button type="submit">Sign in</button>
</form>
{{else}}
<div class="mark">M</div>
<h1>Install Memby</h1>
{{if .Ready}}
<p class="intro">The private Android TV client for this households Emby library.</p>
<a class="download" href="{{.DownloadURL}}">Download Memby {{.Version}}</a>
<p class="meta">Signed Android APK · {{.Size}}</p>
<ol>
<li>Open this page on the TV using a browser or the Downloader app.</li>
<li>Choose <strong>Download</strong>. If Android asks, allow that app to install unknown apps.</li>
<li>Open the downloaded APK and choose <strong>Install</strong>, then launch Memby and sign in.</li>
</ol>
{{if .Notes}}<p class="notes"><strong>Latest release:</strong> {{.Notes}}</p>{{end}}
<form class="logout" method="post" action="/install/logout"><button type="submit">Sign out</button></form>
{{else}}
<p class="unavailable">The installer is not available yet. Publish a signed Memby release, then refresh this page.</p>
{{end}}
{{end}}
</main>
</body>
</html>
+206
View File
@@ -0,0 +1,206 @@
package api
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"net/http"
"net/url"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
)
const (
installerCookieName = "memby_installer"
installerSessionTTL = 30 * time.Minute
installerDeviceID = "memby-web-installer"
installerDeviceName = "Memby Web Installer"
)
func (s *Server) installerSecret() []byte {
if s.cfg.ReleasePublishToken == "" {
return nil
}
// Domain separation means a cookie/signature is not the release publisher token and
// cannot be presented to the upload endpoint.
sum := sha256.Sum256([]byte("memby installer access v1\x00" + s.cfg.ReleasePublishToken))
return sum[:]
}
func (s *Server) signInstallerValue(purpose string, payload []byte) []byte {
mac := hmac.New(sha256.New, s.installerSecret())
_, _ = mac.Write([]byte(purpose))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write(payload)
return mac.Sum(nil)
}
func (s *Server) newInstallerSession() (string, error) {
payload := make([]byte, 8+16)
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(installerSessionTTL).Unix()))
if _, err := rand.Read(payload[8:]); err != nil {
return "", err
}
signature := s.signInstallerValue("session", payload)
return base64.RawURLEncoding.EncodeToString(payload) + "." +
base64.RawURLEncoding.EncodeToString(signature), nil
}
func (s *Server) validInstallerSession(r *http.Request) bool {
if len(s.installerSecret()) == 0 {
return false
}
cookie, err := r.Cookie(installerCookieName)
if err != nil {
return false
}
parts := strings.Split(cookie.Value, ".")
if len(parts) != 2 {
return false
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil || len(payload) != 24 {
return false
}
signature, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(signature, s.signInstallerValue("session", payload)) {
return false
}
expires := int64(binary.BigEndian.Uint64(payload[:8]))
now := time.Now().Unix()
return expires > now && expires <= now+int64(installerSessionTTL/time.Second)+60
}
func (s *Server) setInstallerCookie(w http.ResponseWriter, value string) {
http.SetCookie(w, &http.Cookie{
Name: installerCookieName,
Value: value,
Path: "/",
MaxAge: int(installerSessionTTL / time.Second),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func (s *Server) clearInstallerCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: installerCookieName,
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func (s *Server) releaseAccessToken(filename string) string {
if len(s.installerSecret()) == 0 || !releaseFilenamePattern.MatchString(filename) {
return ""
}
return base64.RawURLEncoding.EncodeToString(
s.signInstallerValue("release", []byte(filename)),
)
}
func (s *Server) signedReleasePath(filename string) string {
token := s.releaseAccessToken(filename)
if token == "" {
return ""
}
return "/updates/" + filename + "?access=" + url.QueryEscape(token)
}
func (s *Server) allowedReleaseDownload(r *http.Request, filename string) bool {
if s.validInstallerSession(r) {
return true
}
expected := s.releaseAccessToken(filename)
presented := strings.TrimSpace(r.URL.Query().Get("access"))
return expected != "" && hmac.Equal([]byte(presented), []byte(expected))
}
func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
if len(s.installerSecret()) == 0 {
http.NotFound(w, r)
return
}
// Password authentication necessarily registers a device with Emby. Do not start
// it unless the service credential needed to remove that temporary record exists.
if s.cfg.SyncAPIKey == "" {
s.log.Error("installer login unavailable: MEMBY_SYNC_API_KEY is not configured")
s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
http.StatusServiceUnavailable, "/install")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
if err := r.ParseForm(); err != nil {
s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusBadRequest, "/install")
return
}
next := cleanInstallerDestination(r.FormValue("next"))
username := strings.TrimSpace(r.FormValue("username"))
password := r.FormValue("password")
if username == "" || password == "" {
s.renderAccessLogin(w, r, "Enter your username and password.", http.StatusBadRequest, next)
return
}
auth, err := s.emby.Authenticate(
r.Context(), username, password, installerDeviceID, installerDeviceName,
)
if err != nil {
s.log.Warn("installer Emby authentication failed", "username", username)
s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusUnauthorized, next)
return
}
// Authentication creates an Emby access token. The installer needs only proof that
// it succeeded, so retire the upstream session immediately and never persist it.
if err := s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: installerDeviceID, DeviceName: installerDeviceName,
}); err != nil {
s.log.Warn("installer Emby session cleanup failed", "error", err)
}
if err := s.emby.DeleteDevice(r.Context(), emby.Credentials{
UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey,
DeviceID: "memby-gateway", DeviceName: "Memby Gateway",
}, installerDeviceID); err != nil {
s.log.Error("installer Emby device cleanup failed", "error", err)
s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
http.StatusBadGateway, next)
return
}
session, err := s.newInstallerSession()
if err != nil {
s.log.Error("installer session generation failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start installer session")
return
}
s.setInstallerCookie(w, session)
http.Redirect(w, r, next, http.StatusSeeOther)
}
func (s *Server) handleInstallLogout(w http.ResponseWriter, r *http.Request) {
s.clearInstallerCookie(w)
http.Redirect(w, r, "/install", http.StatusSeeOther)
}
func cleanInstallerDestination(value string) string {
value = strings.TrimSpace(value)
if value == "/admin/" {
return "/admin/"
}
if strings.HasPrefix(value, "/admin/") {
page := strings.TrimPrefix(value, "/admin/")
if adminPages[page] {
return value
}
}
return "/install"
}
+146 -2
View File
@@ -1,11 +1,15 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -17,6 +21,22 @@ type flagRequest struct {
Value bool `json:"value"`
}
type seasonFinaleResponse struct {
SeasonFinale bool `json:"seasonFinale"`
SeriesName string `json:"seriesName,omitempty"`
SeasonNumber int `json:"seasonNumber,omitempty"`
EpisodeNumber int `json:"episodeNumber,omitempty"`
}
type finaleEmbyItem struct {
Type string `json:"Type"`
SeriesID string `json:"SeriesId"`
SeriesName string `json:"SeriesName"`
ParentIndexNumber int `json:"ParentIndexNumber"`
IndexNumber int `json:"IndexNumber"`
ProviderIDs map[string]string `json:"ProviderIds"`
}
// handleItem serves full metadata for one item. The TV asks for this only after D-pad
// focus settles, so it is worth caching for longer than a home row.
func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.Session) {
@@ -28,7 +48,7 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
}
// Version the entry when the detail contract grows so older cached payloads cannot
// hide newly requested fields such as People.
key := cache.UserKey(sess.EmbyUserID, "item:v2:"+itemID)
key := cache.UserKey(sess.EmbyUserID, "item:v3:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
@@ -48,6 +68,131 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
writeRaw(w, http.StatusOK, item)
}
// handleSeasonFinale verifies an episode against Sonarr's complete season, including
// future episodes. Looking only at Emby's downloaded files would call every current
// weekly episode a finale until the following episode arrived.
func (s *Server) handleSeasonFinale(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
empty := seasonFinaleResponse{}
if s.sonarr == nil {
writeJSON(w, http.StatusOK, empty)
return
}
key := cache.UserKey(sess.EmbyUserID, "season-finale:v1:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
writeRaw(w, http.StatusOK, raw)
return
}
currentRaw, err := s.emby.Item(
ctx, credentials(sess), itemID,
"ProviderIds,SeriesId,SeriesName,ParentIndexNumber,IndexNumber",
)
if err != nil {
s.writeUpstreamError(w, err, "could not inspect the playing episode")
return
}
var current finaleEmbyItem
if json.Unmarshal(currentRaw, &current) != nil ||
!strings.EqualFold(current.Type, "Episode") || current.SeriesID == "" ||
current.ParentIndexNumber <= 0 || current.IndexNumber <= 0 {
s.writeSeasonFinaleResponse(ctx, key, empty, w)
return
}
seriesRaw, err := s.emby.Item(ctx, credentials(sess), current.SeriesID, "ProviderIds")
if err != nil {
s.log.Warn("season finale series metadata unavailable", "item", itemID, "error", err)
s.writeSeasonFinaleResponse(ctx, key, empty, w)
return
}
var seriesItem finaleEmbyItem
if json.Unmarshal(seriesRaw, &seriesItem) != nil {
s.writeSeasonFinaleResponse(ctx, key, empty, w)
return
}
tvdbID, err := strconv.Atoi(providerID(seriesItem.ProviderIDs, "tvdb"))
if err != nil || tvdbID <= 0 {
s.writeSeasonFinaleResponse(ctx, key, empty, w)
return
}
series, err := s.sonarr.Series(ctx)
if err != nil {
s.log.Warn("season finale Sonarr series unavailable", "item", itemID, "error", err)
s.writeSeasonFinaleResponse(ctx, key, empty, w)
return
}
sonarrSeriesID := 0
for _, candidate := range series {
if candidate.TVDBID == tvdbID {
sonarrSeriesID = candidate.ID
break
}
}
if sonarrSeriesID == 0 {
s.writeSeasonFinaleResponse(ctx, key, empty, w)
return
}
episodes, err := s.sonarr.Episodes(ctx, sonarrSeriesID)
if err != nil {
s.log.Warn("season finale Sonarr episodes unavailable", "item", itemID, "error", err)
s.writeSeasonFinaleResponse(ctx, key, empty, w)
return
}
result := seasonFinaleResponse{
SeasonFinale: isSeasonFinale(current.ParentIndexNumber, current.IndexNumber, episodes),
SeriesName: current.SeriesName,
SeasonNumber: current.ParentIndexNumber,
EpisodeNumber: current.IndexNumber,
}
if !result.SeasonFinale {
result = empty
}
s.writeSeasonFinaleResponse(ctx, key, result, w)
}
func (s *Server) writeSeasonFinaleResponse(
ctx context.Context, key string, result seasonFinaleResponse, w http.ResponseWriter,
) {
body, err := json.Marshal(result)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not encode finale status")
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil {
s.log.Warn("season finale cache write failed", "error", err)
}
writeRaw(w, http.StatusOK, body)
}
func providerID(ids map[string]string, wanted string) string {
for key, value := range ids {
if strings.EqualFold(key, wanted) {
return value
}
}
return ""
}
func isSeasonFinale(seasonNumber, episodeNumber int, episodes []sonarr.Episode) bool {
if seasonNumber <= 0 || episodeNumber <= 0 {
return false
}
lastEpisode := 0
for _, episode := range episodes {
if episode.SeasonNumber == seasonNumber && episode.EpisodeNumber > lastEpisode {
lastEpisode = episode.EpisodeNumber
}
}
return lastEpisode > 0 && episodeNumber == lastEpisode
}
// handleSeriesEpisodes supplies the complete episode browser in one cached response.
// The TV groups by ParentIndexNumber locally, so changing seasons never reaches Emby.
func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, sess store.Session) {
@@ -154,7 +299,6 @@ func (s *Server) setFlag(
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, true)
}
writeRaw(w, http.StatusOK, userData)
}
+9 -1
View File
@@ -101,11 +101,15 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, _ s
// Nothing to celebrate while the service is down, and the client is showing the
// maintenance screen anyway.
if !state.Enabled {
if found := s.sonarrAiredAlerts(r.Context()); len(found) > 0 {
if found := mergeAlerts(
s.publishedAlerts(r.Context()),
s.sonarrAiredAlerts(r.Context()),
); len(found) > 0 {
alerts = found
}
}
compatible, compatibilityMessage := compatibilityFor(r)
featurePolicy := s.currentFeaturePolicy(r.Context())
writeJSON(w, http.StatusOK, map[string]any{
"maintenance": state.Enabled,
"message": message,
@@ -115,5 +119,9 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, _ s
"clientVersion": clientVersion(r),
"clientProtocol": clientProtocol(r),
"serverProtocol": membyProtocolVersion,
"featureSchemaVersion": featureSchemaVersion,
"featureRevision": featurePolicy.Revision,
"safeMode": featurePolicy.SafeMode,
"features": featureMap(featurePolicy, clientProtocolNumber(r), clientCapabilities(r)),
})
}
+255
View File
@@ -0,0 +1,255 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
type myShowResponse struct {
store.UserShow
SonarrStatus string `json:"sonarrStatus"`
NextEpisode *time.Time `json:"nextEpisode,omitempty"`
Lifecycle string `json:"lifecycle"`
Monitored bool `json:"monitored"`
}
type myShowsResponse struct {
Shows []myShowResponse `json:"shows"`
}
type notificationsResponse struct {
Notifications []store.UserNotification `json:"notifications"`
Preferences store.NotificationPreferences `json:"preferences"`
}
func (s *Server) handleMyShows(w http.ResponseWriter, r *http.Request, sess store.Session) {
switch r.Method {
case http.MethodGet:
s.listMyShows(w, r, sess)
case http.MethodPost:
var show store.UserShow
if !decodeJSON(w, r, &show) {
return
}
show.ItemID = strings.TrimSpace(show.ItemID)
show.Title = strings.TrimSpace(show.Title)
if show.ItemID == "" || show.Title == "" {
writeError(w, http.StatusBadRequest, "itemId and title are required")
return
}
if err := s.store.SaveUserShow(r.Context(), sess.EmbyUserID, show); err != nil {
s.log.Error("save user show failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save show")
return
}
s.listMyShows(w, r, sess)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
func (s *Server) handleMyShow(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := strings.TrimSpace(r.PathValue("id"))
if itemID == "" {
writeError(w, http.StatusBadRequest, "show id is required")
return
}
if err := s.store.DeleteUserShow(r.Context(), sess.EmbyUserID, itemID); err != nil {
s.log.Error("delete user show failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not remove show")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) listMyShows(w http.ResponseWriter, r *http.Request, sess store.Session) {
saved, err := s.store.UserShows(r.Context(), sess.EmbyUserID)
if err != nil {
s.log.Error("list user shows failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not load shows")
return
}
sonarrSeries := []sonarr.Series{}
if s.sonarr != nil {
if value, seriesErr := s.sonarr.Series(r.Context()); seriesErr == nil {
sonarrSeries = value
} else {
s.log.Warn("Sonarr status unavailable for My Shows", "error", seriesErr)
}
}
result := make([]myShowResponse, 0, len(saved))
for _, show := range saved {
matched := matchSonarrSeries(show, sonarrSeries)
response := myShowResponse{
UserShow: show,
SonarrStatus: "Not found",
Lifecycle: "Unknown",
}
if matched != nil {
response.SonarrStatus = sonarrStatus(*matched)
response.NextEpisode = matched.NextAiring
response.Lifecycle = seriesLifecycle(matched.Status)
response.Monitored = matched.Monitored
}
result = append(result, response)
}
writeJSON(w, http.StatusOK, myShowsResponse{Shows: result})
}
func matchSonarrSeries(show store.UserShow, all []sonarr.Series) *sonarr.Series {
key := normalizedShowTitle(show.Title)
for i := range all {
candidate := &all[i]
if normalizedShowTitle(candidate.Title) != key {
continue
}
if show.Year == nil || candidate.Year == 0 || candidate.Year == *show.Year {
return candidate
}
}
return nil
}
func normalizedShowTitle(value string) string {
return strings.Map(func(r rune) rune {
if r >= 'A' && r <= 'Z' {
return r + ('a' - 'A')
}
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
return r
}
return -1
}, value)
}
func seriesLifecycle(status string) string {
switch strings.ToLower(status) {
case "continuing", "upcoming":
return "Continuing"
case "ended", "deleted":
return "Cancelled"
default:
if status == "" {
return "Unknown"
}
return strings.ToUpper(status[:1]) + strings.ToLower(status[1:])
}
}
func isContinuingSonarrStatus(status string) bool {
return strings.EqualFold(status, "continuing") || strings.EqualFold(status, "upcoming")
}
func sonarrStatus(series sonarr.Series) string {
if series.Monitored {
return "Monitored"
}
return "Not monitored"
}
func (s *Server) handleNotifications(w http.ResponseWriter, r *http.Request, sess store.Session) {
prefs, err := s.store.NotificationPreferences(r.Context(), sess.EmbyUserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load notification preferences")
return
}
if r.Method == http.MethodPut {
if !decodeJSON(w, r, &prefs) {
return
}
if err := s.store.SetNotificationPreferences(r.Context(), sess.EmbyUserID, prefs); err != nil {
writeError(w, http.StatusInternalServerError, "could not save notification preferences")
return
}
}
if prefs.Enabled && prefs.ShowReturnAlerts {
s.syncReturnNotifications(r, sess, prefs)
}
notifications, err := s.store.UserNotifications(r.Context(), sess.EmbyUserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load notifications")
return
}
writeJSON(w, http.StatusOK, notificationsResponse{
Notifications: notifications,
Preferences: prefs,
})
}
func (s *Server) syncReturnNotifications(
r *http.Request, sess store.Session, prefs store.NotificationPreferences,
) {
if s.sonarr == nil {
return
}
shows, err := s.store.UserShows(r.Context(), sess.EmbyUserID)
if err != nil {
return
}
all, err := s.sonarr.Series(r.Context())
if err != nil {
return
}
now := time.Now()
until := now.AddDate(0, 0, prefs.LeadDays)
for _, show := range shows {
series := matchSonarrSeries(show, all)
if series == nil || series.NextAiring == nil ||
series.NextAiring.Before(now) || series.NextAiring.After(until) {
continue
}
days := int(series.NextAiring.Sub(now).Hours()/24) + 1
message := fmt.Sprintf("%s returns in %d days.", show.Title, days)
if days <= 1 {
message = show.Title + " returns tomorrow."
} else if days == 7 {
message = show.Title + " returns next week."
}
sourceKey := "show-return:" + show.ItemID + ":" + series.NextAiring.UTC().Format("2006-01-02")
_ = s.store.UpsertNotification(
r.Context(), sess.EmbyUserID, sourceKey, "show-return", show.ItemID,
"New episode coming", message, series.NextAiring,
)
}
}
func (s *Server) handleNotificationAction(
w http.ResponseWriter, r *http.Request, sess store.Session,
) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id < 1 {
writeError(w, http.StatusBadRequest, "invalid notification id")
return
}
switch r.PathValue("action") {
case "read":
err = s.store.MarkNotificationRead(r.Context(), sess.EmbyUserID, id)
case "dismiss":
err = s.store.DismissNotification(r.Context(), sess.EmbyUserID, id)
default:
writeError(w, http.StatusNotFound, "unknown notification action")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "could not update notification")
return
}
w.WriteHeader(http.StatusNoContent)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, out any) bool {
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(out); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return false
}
return true
}
+63
View File
@@ -0,0 +1,63 @@
package api
import (
"testing"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestMatchSonarrSeriesUsesNormalizedTitleAndYear(t *testing.T) {
year := 2026
all := []sonarr.Series{
{ID: 1, Title: "The Other Show", Year: 2026},
{ID: 2, Title: "Northbound: NZ", Year: 2026},
}
got := matchSonarrSeries(store.UserShow{Title: "Northbound - NZ", Year: &year}, all)
if got == nil || got.ID != 2 {
t.Fatalf("match = %+v, want series 2", got)
}
}
func TestSeriesLifecycleUsesViewerFacingStates(t *testing.T) {
tests := map[string]string{
"continuing": "Continuing",
"upcoming": "Continuing",
"ended": "Cancelled",
"deleted": "Cancelled",
"": "Unknown",
}
for input, want := range tests {
if got := seriesLifecycle(input); got != want {
t.Errorf("seriesLifecycle(%q) = %q, want %q", input, got, want)
}
}
}
func TestContinuingSonarrStatus(t *testing.T) {
for _, status := range []string{"continuing", "Continuing", "upcoming"} {
if !isContinuingSonarrStatus(status) {
t.Errorf("%q should auto-follow", status)
}
}
for _, status := range []string{"ended", "deleted", ""} {
if isContinuingSonarrStatus(status) {
t.Errorf("%q should not auto-follow", status)
}
}
}
func TestAutoFollowStartsHalfwayThroughEpisode(t *testing.T) {
if shouldAutoFollowShow("started", 900, 1_000) {
t.Fatal("starting or resuming playback must not auto-follow")
}
if shouldAutoFollowShow("progress", 499, 1_000) {
t.Fatal("less than half an episode must not auto-follow")
}
if !shouldAutoFollowShow("progress", 500, 1_000) {
t.Fatal("half an episode should auto-follow")
}
if shouldAutoFollowShow("progress", 500, 0) {
t.Fatal("unknown durations must not auto-follow")
}
}
+268 -39
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
@@ -15,14 +16,20 @@ import (
const ticksPerMillisecond = 10_000
type playbackResponse struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
Subtitles []playableSubtitle `json:"subtitles"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
ItemID string `json:"itemId"`
Title string `json:"title"`
Overview string `json:"overview,omitempty"`
SeriesName string `json:"seriesName,omitempty"`
EpisodeCode string `json:"episodeCode,omitempty"`
RuntimeMs int64 `json:"runtimeMs,omitempty"`
PrerollEnabled bool `json:"prerollEnabled"`
PrerollDurationMs int64 `json:"prerollDurationMs"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
Subtitles []playableSubtitle `json:"subtitles"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
}
type playableSubtitle struct {
@@ -41,6 +48,7 @@ type playableSubtitle struct {
type playbackReport struct {
ItemID string `json:"itemId"`
PositionMs int64 `json:"positionMs"`
DurationMs int64 `json:"durationMs,omitempty"`
IsPaused bool `json:"isPaused"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
@@ -48,6 +56,10 @@ type playbackReport struct {
EventName string `json:"eventName,omitempty"`
}
type playbackReportResponse struct {
AutoFollowedShowTitle string `json:"autoFollowedShowTitle,omitempty"`
}
// nextEpisodeResponse carries the episode that follows the one being watched. Item is
// Emby's own item JSON, forwarded verbatim like every other item the gateway returns, so
// the client decodes it into the same BaseItem it uses everywhere else.
@@ -117,20 +129,35 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
}
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
ctx, cred, target.ID, target.UserData.PlaybackPositionTicks, subtitleIndex, "",
queryBool(r, "forceTranscode"), s.effectivePlaybackCapabilities(ctx, sess),
)
streamURL := s.emby.StreamURL(cred, target.ID)
if negotiatedURL != "" {
streamURL = negotiatedURL
}
playbackPolicy := store.DefaultPlaybackPolicy()
if s.store != nil {
if policy, policyErr := s.store.PlaybackPolicy(ctx); policyErr == nil {
playbackPolicy = policy
} else {
s.log.Warn("playback policy unavailable", "error", policyErr)
}
}
writeJSON(w, http.StatusOK, playbackResponse{
ItemID: target.ID,
Title: title,
URL: streamURL,
ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
ItemID: target.ID,
Title: title,
Overview: target.Overview,
SeriesName: target.SeriesName,
EpisodeCode: episodeCode(target),
RuntimeMs: max64(target.RunTimeTicks/ticksPerMillisecond, 0),
PrerollEnabled: playbackPolicy.PrerollEnabled && s.featureEnabled(ctx, featureSonarrPreroll),
PrerollDurationMs: playbackPolicy.PrerollDurationMs,
URL: streamURL,
ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
})
}
@@ -164,7 +191,7 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials
nextUp, err := s.emby.NextUp(ctx, cred, url.Values{
"SeriesId": {seriesID},
"Limit": {"1"},
"Fields": {"RunTimeTicks"},
"Fields": {"Overview,RunTimeTicks,SeriesName,ParentIndexNumber,IndexNumber"},
"EnableUserData": {"true"},
})
if err == nil && len(nextUp.Items) > 0 {
@@ -175,7 +202,7 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials
episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{
"Limit": {"1"},
"Fields": {"RunTimeTicks"},
"Fields": {"Overview,RunTimeTicks,SeriesName,ParentIndexNumber,IndexNumber"},
"EnableUserData": {"true"},
})
if err != nil {
@@ -191,6 +218,13 @@ func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials
return &summary, nil
}
func episodeCode(item emby.Summary) string {
if !strings.EqualFold(item.Type, "Episode") || item.ParentIndexNumber < 0 || item.IndexNumber <= 0 {
return ""
}
return fmt.Sprintf("S%02dE%02d", item.ParentIndexNumber, item.IndexNumber)
}
// handleNextEpisode resolves the episode that follows the one being watched, so the player
// can offer a "next up" countdown without the TV needing to know how Emby orders a series.
//
@@ -249,13 +283,18 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
if series := strings.TrimSpace(seriesNameOf(raw)); series != "" && title != "" {
title = series + " " + title
}
subtitles, mediaSourceID, playSessionID, _, playMethod := s.playbackSubtitles(
ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "",
subtitles, mediaSourceID, playSessionID, negotiatedURL, playMethod := s.playbackSubtitles(
ctx, cred, next.ID, next.UserData.PlaybackPositionTicks, nil, "", false,
s.effectivePlaybackCapabilities(ctx, sess),
)
streamURL := s.emby.StreamURL(cred, next.ID)
if negotiatedURL != "" {
streamURL = negotiatedURL
}
writeJSON(w, http.StatusOK, nextEpisodeResponse{
Item: raw,
Title: title,
URL: s.emby.StreamURL(cred, next.ID),
URL: streamURL,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
MediaSourceID: mediaSourceID,
@@ -266,10 +305,12 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
func (s *Server) playbackSubtitles(
ctx context.Context, cred emby.Credentials, itemID string, startTicks int64,
subtitleIndex *int, currentPlaySessionID string,
subtitleIndex *int, currentPlaySessionID string, forceTranscode bool,
capabilities emby.PlaybackCapabilities,
) ([]playableSubtitle, string, string, string, string) {
info, err := s.emby.PlaybackInfo(
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID,
ctx, cred, itemID, startTicks, subtitleIndex, currentPlaySessionID, forceTranscode,
capabilities,
)
if err != nil {
s.log.Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
@@ -326,13 +367,128 @@ func (s *Server) playbackSubtitles(
Codec: stream.Codec,
})
}
negotiatedURL := ""
playMethod := "DirectPlay"
if subtitleIndex != nil && source.TranscodingURL != "" {
negotiatedURL = s.emby.DeliveryURL(cred, source.TranscodingURL)
playMethod = "Transcode"
delivery, playMethod := selectPlaybackDelivery(source, forceTranscode || subtitleIndex != nil)
if delivery != "" {
delivery = s.emby.DeliveryURL(cred, delivery)
}
return out, source.ID, info.PlaySessionID, negotiatedURL, playMethod
return out, source.ID, info.PlaySessionID, delivery, playMethod
}
func sessionPlaybackCapabilities(sess store.Session) emby.PlaybackCapabilities {
capabilities := emby.PlaybackCapabilities{}
for _, value := range sess.ClientCapabilities {
switch value {
case "video_h264_profile_baseline":
capabilities.H264Profiles = append(capabilities.H264Profiles, "baseline")
case "video_h264_profile_constrained_baseline":
capabilities.H264Profiles = append(capabilities.H264Profiles, "constrained baseline")
case "video_h264_profile_main":
capabilities.H264Profiles = append(capabilities.H264Profiles, "main")
case "video_h264_profile_high":
capabilities.H264Profiles = append(capabilities.H264Profiles, "high")
case "video_h264_profile_high10":
capabilities.H264Profiles = append(capabilities.H264Profiles, "high 10")
case "video_hevc_decode":
capabilities.HEVC = true
case "video_hevc_profile_main":
capabilities.HEVCMain = true
case "video_hevc_profile_main10":
capabilities.HEVCMain10 = true
case "video_hevc_hdr10":
capabilities.HEVCHDR10 = true
case "video_hevc_hdr10plus":
capabilities.HEVCHDR10Plus = true
case "video_hevc_dolby_vision":
capabilities.HEVCDolbyVision = true
default:
parsePlaybackCapabilityValue(value, &capabilities)
}
}
return capabilities
}
func (s *Server) effectivePlaybackCapabilities(
ctx context.Context, sess store.Session,
) emby.PlaybackCapabilities {
capabilities := sessionPlaybackCapabilities(sess)
if !s.featureEnabled(ctx, featureHEVCDirectPlay) {
capabilities.HEVC = false
capabilities.HEVCMain = false
capabilities.HEVCMain10 = false
capabilities.HEVCMainLevel = 0
capabilities.HEVCMain10Level = 0
capabilities.HEVCMaxWidth = 0
capabilities.HEVCMaxHeight = 0
capabilities.HEVCHDR10 = false
capabilities.HEVCHDR10Plus = false
capabilities.HEVCDolbyVision = false
}
return capabilities
}
func parsePlaybackCapabilityValue(value string, capabilities *emby.PlaybackCapabilities) {
parseIntCapability(value, "video_h264_level_", &capabilities.H264Level)
parseIntCapability(value, "video_h264_high10_level_", &capabilities.H264High10Level)
parseIntCapability(value, "video_hevc_main_level_", &capabilities.HEVCMainLevel)
parseIntCapability(value, "video_hevc_main10_level_", &capabilities.HEVCMain10Level)
parseResolutionCapability(
value, "video_h264_max_", &capabilities.H264MaxWidth, &capabilities.H264MaxHeight,
)
parseResolutionCapability(
value, "video_hevc_max_", &capabilities.HEVCMaxWidth, &capabilities.HEVCMaxHeight,
)
}
func parseIntCapability(value, prefix string, destination *int) {
if !strings.HasPrefix(value, prefix) {
return
}
parsed, err := strconv.Atoi(strings.TrimPrefix(value, prefix))
if err == nil && parsed > 0 {
*destination = parsed
}
}
func parseResolutionCapability(value, prefix string, width, height *int) {
if !strings.HasPrefix(value, prefix) {
return
}
parts := strings.Split(strings.TrimPrefix(value, prefix), "x")
if len(parts) != 2 {
return
}
parsedWidth, widthErr := strconv.Atoi(parts[0])
parsedHeight, heightErr := strconv.Atoi(parts[1])
if widthErr == nil && heightErr == nil && parsedWidth > 0 && parsedHeight > 0 {
*width, *height = parsedWidth, parsedHeight
}
}
func selectPlaybackDelivery(source emby.MediaSourceInfo, forceTranscode bool) (string, string) {
if forceTranscode && source.TranscodingURL != "" {
return source.TranscodingURL, "Transcode"
}
if source.SupportsDirectPlay {
return "", "DirectPlay"
}
if source.SupportsDirectStream && source.DirectStreamURL != "" {
return source.DirectStreamURL, "DirectStream"
}
if source.SupportsTranscoding && source.TranscodingURL != "" {
return source.TranscodingURL, "Transcode"
}
if source.DirectStreamURL != "" {
return source.DirectStreamURL, "DirectStream"
}
if source.TranscodingURL != "" {
return source.TranscodingURL, "Transcode"
}
return "", "DirectPlay"
}
func queryBool(r *http.Request, name string) bool {
value, err := strconv.ParseBool(strings.TrimSpace(r.URL.Query().Get(name)))
return err == nil && value
}
func subtitleExtension(codec string) string {
@@ -445,17 +601,90 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
s.log.Warn("cache invalidation failed", "error", err)
}
// Finishing something is the one event that genuinely changes viewing history,
// so it is also the only thing that retires the recommendation rows.
if err := s.cache.InvalidateRecommendations(r.Context(), sess.EmbyUserID); err != nil {
s.log.Warn("recommendation invalidation failed", "error", err)
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, true)
}
// Recommendation taste changes slowly. Tracearr marks this user's prepared
// profile dirty only when the session first becomes terminal; the daily builder
// then refreshes it without turning every player exit into catalogue-wide work.
}
w.WriteHeader(http.StatusNoContent)
response := playbackReportResponse{}
if shouldAutoFollowShow(phase, report.PositionMs, report.DurationMs) &&
s.featureEnabled(r.Context(), featureAutomaticMyShows) {
response.AutoFollowedShowTitle = s.autoFollowContinuingShow(r.Context(), sess, report.ItemID)
}
writeJSON(w, http.StatusOK, response)
}
// Half an episode is a meaningful intent signal without making somebody finish an
// episode they dislike. The insert below is the durable deduplication boundary, so
// later ten-second progress reports are harmless.
func shouldAutoFollowShow(phase string, positionMs, durationMs int64) bool {
return phase != "started" && durationMs > 0 && positionMs >= (durationMs+1)/2
}
func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Session, episodeID string) string {
if s.sonarr == nil || s.store == nil {
return ""
}
rawEpisode, err := s.emby.Item(ctx, credentials(sess), episodeID, "SeriesId")
if err != nil {
s.log.Warn("auto-follow episode lookup failed", "error", err)
return ""
}
var episode struct {
Type string `json:"Type"`
SeriesID string `json:"SeriesId"`
}
if json.Unmarshal(rawEpisode, &episode) != nil || !strings.EqualFold(episode.Type, "Episode") || episode.SeriesID == "" {
return ""
}
rawSeries, err := s.emby.Item(ctx, credentials(sess), episode.SeriesID, "ProductionYear")
if err != nil {
s.log.Warn("auto-follow series lookup failed", "error", err)
return ""
}
var seriesItem struct {
Name string `json:"Name"`
ProductionYear *int `json:"ProductionYear"`
ImageTags map[string]string `json:"ImageTags"`
}
if json.Unmarshal(rawSeries, &seriesItem) != nil || strings.TrimSpace(seriesItem.Name) == "" {
return ""
}
sonarrSeries, err := s.sonarr.Series(ctx)
if err != nil {
s.log.Warn("auto-follow Sonarr lookup failed", "error", err)
return ""
}
matched := matchSonarrSeries(store.UserShow{Title: seriesItem.Name, Year: seriesItem.ProductionYear}, sonarrSeries)
if matched == nil || !isContinuingSonarrStatus(matched.Status) {
return ""
}
show := store.UserShow{
ItemID: episode.SeriesID, Title: seriesItem.Name, Year: seriesItem.ProductionYear,
ImageTag: seriesItem.ImageTags["Primary"],
}
inserted, err := s.store.SaveUserShowIfAbsent(ctx, sess.EmbyUserID, show)
if err != nil {
s.log.Warn("auto-follow save failed", "error", err)
return ""
}
if !inserted {
return ""
}
prefs, err := s.store.NotificationPreferences(ctx, sess.EmbyUserID)
if err != nil {
s.log.Warn("auto-follow notification preferences unavailable", "error", err)
return ""
}
if prefs.Enabled && s.featureEnabled(ctx, featureMyShowsNotification) {
_ = s.store.UpsertNotification(
ctx, sess.EmbyUserID, "auto-follow:"+episode.SeriesID, "auto-follow",
episode.SeriesID, "Added to My Shows",
seriesItem.Name+" was added because you started watching it and it is still continuing.", nil,
)
return seriesItem.Name
}
return ""
}
func max64(v, floor int64) int64 {
+47 -1
View File
@@ -1,6 +1,11 @@
package api
import "testing"
import (
"testing"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestSubtitleMIME(t *testing.T) {
tests := map[string]string{
@@ -18,6 +23,47 @@ func TestSubtitleMIME(t *testing.T) {
}
}
func TestSelectPlaybackDeliveryUsesNegotiatedDirectStream(t *testing.T) {
source := emby.MediaSourceInfo{
SupportsDirectStream: true,
DirectStreamURL: "/Videos/item/stream.mp4",
TranscodingURL: "/Videos/item/master.m3u8",
}
got, method := selectPlaybackDelivery(source, false)
if got != source.DirectStreamURL || method != "DirectStream" {
t.Fatalf("selectPlaybackDelivery() = %q, %q", got, method)
}
}
func TestSelectPlaybackDeliveryCanForceCompatibleTranscode(t *testing.T) {
source := emby.MediaSourceInfo{
SupportsDirectPlay: true,
TranscodingURL: "/Videos/item/master.m3u8",
}
got, method := selectPlaybackDelivery(source, true)
if got != source.TranscodingURL || method != "Transcode" {
t.Fatalf("selectPlaybackDelivery() = %q, %q", got, method)
}
}
func TestSessionPlaybackCapabilitiesParseDecoderDetails(t *testing.T) {
legacy := sessionPlaybackCapabilities(store.Session{})
if legacy.HEVC {
t.Fatal("legacy client must not implicitly claim HEVC")
}
got := sessionPlaybackCapabilities(store.Session{ClientCapabilities: []string{
"video_h264_profile_high", "video_h264_level_52", "video_h264_max_3840x2160",
"video_hevc_decode", "video_hevc_profile_main10", "video_hevc_main10_level_153",
"video_hevc_max_3840x2160", "video_hevc_hdr10",
}})
if !got.HEVC || !got.HEVCMain10 || !got.HEVCHDR10 || got.HEVCMain10Level != 153 {
t.Fatalf("HEVC capabilities = %+v", got)
}
if got.H264Level != 52 || got.H264MaxWidth != 3840 || got.H264MaxHeight != 2160 {
t.Fatalf("H264 capabilities = %+v", got)
}
}
func TestSubtitleMIMEFallsBackToDeliveryExtension(t *testing.T) {
if got := subtitleMIME("", "https://emby.example/subtitles/4/stream.vtt?api_key=x"); got != "text/vtt" {
t.Fatalf("subtitleMIME delivery fallback = %q", got)
+228
View File
@@ -0,0 +1,228 @@
package api
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend"
)
const radarrCalendarCachePrefix = "radarr:calendar:v2:"
const radarrScheduleDays = 5
const radarrTheatricalDelayDays = 30
type radarrRelease struct {
at time.Time
estimated bool
}
type radarrScheduleItem struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
Overview string `json:"Overview,omitempty"`
ProductionYear int `json:"ProductionYear,omitempty"`
RunTimeTicks int64 `json:"RunTimeTicks,omitempty"`
Genres []string `json:"Genres"`
ImageTags map[string]string `json:"ImageTags"`
BackdropImageTags []string `json:"BackdropImageTags"`
MembySource string `json:"MembySource"`
MembyAirsAt string `json:"MembyAirsAt"`
MembyAirDayLabel string `json:"MembyAirDayLabel"`
MembyAirLabel string `json:"MembyAirLabel"`
MembyAvailability string `json:"MembyAvailability"`
MembyAvailabilityText string `json:"MembyAvailabilityText"`
MembyPlayable bool `json:"MembyPlayable"`
}
func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) {
if s.radarr == nil {
return nil, nil
}
location := s.cfg.RadarrLocation
if location == nil {
location = time.Local
}
now := time.Now().In(location)
dayStart := localDayStart(now, location)
cacheKey := radarrCalendarCachePrefix + dayStart.Format("2006-01-02")
if row := s.cachedRadarrRow(ctx, cacheKey); row != nil {
return row, nil
}
s.radarrMu.Lock()
defer s.radarrMu.Unlock()
if row := s.cachedRadarrRow(ctx, cacheKey); row != nil {
return row, nil
}
// A cinema release can stand in for an unknown digital date at cinema + 30 days,
// so include the preceding 30 days in the Radarr query. buildRadarrRow applies the
// actual five-day effective-release window after the response arrives.
movies, err := s.radarr.Calendar(
ctx,
dayStart.AddDate(0, 0, -radarrTheatricalDelayDays),
dayStart.AddDate(0, 0, radarrScheduleDays),
)
if err != nil {
return nil, err
}
row, err := buildRadarrRow(movies, now, location)
if err != nil {
return nil, err
}
if body, marshalErr := json.Marshal(row); marshalErr == nil {
if cacheErr := s.cache.Set(ctx, cacheKey, body, s.cfg.RadarrTTL); cacheErr != nil {
s.log.Warn("radarr calendar cache write failed", "error", cacheErr)
}
}
return row, nil
}
func (s *Server) cachedRadarrRow(ctx context.Context, key string) *recommend.Row {
raw, err := s.cache.Get(ctx, key)
if err != nil {
return nil
}
var row recommend.Row
if json.Unmarshal(raw, &row) != nil {
return nil
}
return &row
}
func buildRadarrRow(movies []radarr.Movie, now time.Time, location *time.Location) (*recommend.Row, error) {
sort.SliceStable(movies, func(i, j int) bool {
left, leftOK := effectiveRadarrRelease(movies[i])
right, rightOK := effectiveRadarrRelease(movies[j])
if !leftOK {
return false
}
if !rightOK {
return true
}
return left.at.Before(right.at)
})
items := make([]json.RawMessage, 0, len(movies))
dayStart := localDayStart(now, location)
windowEnd := dayStart.AddDate(0, 0, radarrScheduleDays)
for _, movie := range movies {
release, ok := effectiveRadarrRelease(movie)
if !ok {
continue
}
localRelease := release.at.In(location)
if localRelease.Before(dayStart) || !localRelease.Before(windowEnd) {
continue
}
raw, err := json.Marshal(toRadarrScheduleItem(movie, release, now, location))
if err != nil {
return nil, err
}
items = append(items, raw)
}
return &recommend.Row{
ID: "radarr-upcoming-movies",
Title: "Upcoming Movie releases",
Kind: "movie-schedule",
Items: items,
}, nil
}
// effectiveRadarrRelease prefers Radarr's actual digital date. Cinema + 30 days is
// only a fallback when Radarr has no digital date at all. In particular, an old movie
// with an old digital date cannot appear because of a newer cinema/re-release date.
func effectiveRadarrRelease(movie radarr.Movie) (radarrRelease, bool) {
if movie.DigitalRelease != nil {
return radarrRelease{at: *movie.DigitalRelease}, true
}
if movie.InCinemas != nil {
return radarrRelease{
at: movie.InCinemas.AddDate(0, 0, radarrTheatricalDelayDays),
estimated: true,
}, true
}
return radarrRelease{}, false
}
func toRadarrScheduleItem(movie radarr.Movie, release radarrRelease, now time.Time, location *time.Location) radarrScheduleItem {
availabilityText := "Upcoming digital release"
if release.estimated {
availabilityText = "Estimated digital release"
}
item := radarrScheduleItem{
ID: fmt.Sprintf("radarr:%d", movie.ID),
Name: movie.Title,
Type: "MembyRadarrMovie",
Overview: movie.Overview,
ProductionYear: movie.Year,
RunTimeTicks: int64(movie.Runtime) * 600_000_000,
Genres: nonNilStrings(movie.Genres),
ImageTags: map[string]string{},
BackdropImageTags: []string{},
MembySource: "radarr",
MembyAvailability: "upcoming",
MembyAvailabilityText: availabilityText,
MembyPlayable: false,
}
if hasRadarrCover(movie.Images, "poster") {
item.ImageTags["Primary"] = "radarr"
}
if hasRadarrCover(movie.Images, "fanart") {
item.BackdropImageTags = []string{"radarr"}
}
localRelease := release.at.In(location)
item.MembyAirsAt = localRelease.Format(time.RFC3339)
item.MembyAirDayLabel = scheduleAirDayLabel(localRelease, now, location)
item.MembyAirLabel = digitalReleaseLabel(localRelease, now, location, release.estimated)
switch {
case movie.HasFile:
item.MembyAvailability = "available"
item.MembyAvailabilityText = "Downloaded"
if movie.MovieFile != nil && movie.MovieFile.DateAdded != nil {
item.MembyAvailabilityText = "Added at " +
movie.MovieFile.DateAdded.In(location).Format("3:04 PM")
}
case !movie.Monitored:
item.MembyAvailability = "unmonitored"
item.MembyAvailabilityText = "Not monitored"
case release.at.Before(now):
item.MembyAvailability = "awaiting"
item.MembyAvailabilityText = "Awaiting download"
}
return item
}
func digitalReleaseLabel(release, now time.Time, location *time.Location, estimated bool) string {
release = release.In(location)
now = now.In(location)
today := localDayStart(now, location)
releaseDay := localDayStart(release, location)
prefix := "Digital release "
if estimated {
prefix = "Estimated digital release "
}
switch {
case releaseDay.Equal(today):
return prefix + "today"
case releaseDay.Equal(today.AddDate(0, 0, 1)):
return prefix + "tomorrow"
default:
return prefix + release.Format("Monday")
}
}
func hasRadarrCover(images []radarr.Image, coverType string) bool {
for _, image := range images {
if strings.EqualFold(image.CoverType, coverType) {
return true
}
}
return false
}
+151
View File
@@ -0,0 +1,151 @@
package api
import (
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// Radarr's import notification arrives as a webhook, which is why this is the one part
// of the gateway something else pushes to. Polling the calendar could only ever notice
// an import a minute or two late and only for titles inside the five-day window;
// "a movie just landed" is an event, so it is delivered as one. The alert itself goes
// into the shared store in alerts.go, like every other event.
// radarrWebhookPayload is the subset of Radarr's webhook body Memby reads. Radarr sends
// considerably more; anything not named here is ignored on purpose, so a Radarr upgrade
// that adds fields cannot break the hook.
type radarrWebhookPayload struct {
EventType string `json:"eventType"`
IsUpgrade bool `json:"isUpgrade"`
Movie struct {
ID int `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
TMDBID int `json:"tmdbId"`
} `json:"movie"`
MovieFile struct {
ID int `json:"id"`
Quality string `json:"quality"`
} `json:"movieFile"`
}
// handleRadarrWebhook accepts Radarr's "On Import" notification.
//
// It sits outside both the auth middleware (Radarr has no Memby session) and the
// maintenance gate (an event dropped while maintenance is on is lost for good, and
// recording one costs nothing while the client API is off).
func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) {
// Unconfigured means absent, the same stance /admin takes: a deployment that never
// set a token must not expose an endpoint that writes to what every TV displays.
if s.cfg.RadarrWebhookToken == "" {
http.NotFound(w, r)
return
}
if subtle.ConstantTimeCompare(
[]byte(webhookToken(r)), []byte(s.cfg.RadarrWebhookToken),
) != 1 {
writeError(w, http.StatusUnauthorized, "invalid webhook token")
return
}
var payload radarrWebhookPayload
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&payload); err != nil {
writeError(w, http.StatusBadRequest, "invalid webhook payload")
return
}
// Radarr's "Test" button posts a stub payload. Answering 200 without announcing a
// film that does not exist is what makes the test button mean "reachable".
if strings.EqualFold(payload.EventType, "Test") {
s.log.Info("radarr webhook test received")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "test": true})
return
}
alert, ok := radarrImportAlert(payload, time.Now().UTC())
if !ok {
// A grab, a rename, a health check or an upgrade of something already in the
// library: all real events, none of them "a new film is here".
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
return
}
if s.cfg.RadarrAlertWindow <= 0 {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": false})
return
}
s.publishAlert(r.Context(), alert, s.cfg.RadarrAlertWindow)
s.log.Info("radarr import announced",
"movie", alert.Title, "alert_id", alert.ID, "quality", payload.MovieFile.Quality)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "announced": true})
}
// webhookToken accepts the shared secret three ways because Radarr's webhook settings
// differ by version: a header where custom headers exist, basic auth where they do not,
// and a query parameter as the form that always works.
func webhookToken(r *http.Request) string {
if token := strings.TrimSpace(r.Header.Get("X-Memby-Token")); token != "" {
return token
}
if token := bearerToken(r); token != "" {
return token
}
if _, password, ok := r.BasicAuth(); ok && password != "" {
return password
}
return strings.TrimSpace(r.URL.Query().Get("token"))
}
// radarrImportAlert turns an import notification into the banner a TV shows, or reports
// that this event is not worth announcing.
//
// An upgrade is deliberately silent: the film was already there, and "new movie added"
// would be a lie about a file that was replaced with a better copy.
func radarrImportAlert(payload radarrWebhookPayload, now time.Time) (clientAlert, bool) {
if !isRadarrImportEvent(payload.EventType) || payload.IsUpgrade {
return clientAlert{}, false
}
title := strings.TrimSpace(payload.Movie.Title)
if title == "" || payload.Movie.ID <= 0 {
return clientAlert{}, false
}
// Keyed on the file, so a title deleted and re-imported is news again while a
// repeated delivery of the same import is not. Clients dedupe on this id forever.
id := fmt.Sprintf("radarr:%d:file:%d", payload.Movie.ID, payload.MovieFile.ID)
if payload.MovieFile.ID <= 0 {
id = fmt.Sprintf("radarr:%d:imported:%d", payload.Movie.ID, now.Unix())
}
name := title
if payload.Movie.Year > 0 {
name = fmt.Sprintf("%s (%d)", title, payload.Movie.Year)
}
return clientAlert{
ID: id,
Kind: alertKindRadarrImport,
Label: "NEW MOVIE ADDED",
Title: name,
Message: fmt.Sprintf("%s will be available in Emby shortly.", title),
// The image proxy already serves Radarr covers under this id and tag, so the
// banner shows the poster before Emby has finished scanning the film in.
ItemID: fmt.Sprintf("radarr:%d", payload.Movie.ID),
ImageTag: "radarr",
AiredAt: now.UTC().Format(time.RFC3339),
}, true
}
// isRadarrImportEvent matches the event Radarr fires once a downloaded file has been
// imported into the library. The name has moved between versions, so both are accepted.
func isRadarrImportEvent(eventType string) bool {
switch strings.ToLower(strings.TrimSpace(eventType)) {
case "download", "moviefileimported":
return true
default:
return false
}
}
+278
View File
@@ -0,0 +1,278 @@
package api
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/config"
)
func importPayload(movieID, fileID int, title string, year int) radarrWebhookPayload {
var payload radarrWebhookPayload
payload.EventType = "Download"
payload.Movie.ID = movieID
payload.Movie.Title = title
payload.Movie.Year = year
payload.MovieFile.ID = fileID
return payload
}
func TestRadarrImportAlertAnnouncesANewFilm(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
alert, ok := radarrImportAlert(importPayload(412, 9001, "Mr. Smith Goes to Washington", 1939), now)
if !ok {
t.Fatal("expected an import to be announced")
}
if alert.ID != "radarr:412:file:9001" {
t.Errorf("alert id = %q, want it keyed on the imported file", alert.ID)
}
if alert.Kind != alertKindRadarrImport {
t.Errorf("kind = %q, want %q", alert.Kind, alertKindRadarrImport)
}
if alert.Label == "" {
t.Error("want a label: the app cannot know the wording for a kind it predates")
}
if alert.Title != "Mr. Smith Goes to Washington (1939)" {
t.Errorf("title = %q, want the year alongside it", alert.Title)
}
if !strings.Contains(alert.Message, "available in Emby shortly") {
t.Errorf("message = %q, want it to say the film is on its way", alert.Message)
}
// The image proxy serves Radarr covers under this pair, so the banner has a poster
// before Emby has scanned the film in.
if alert.ItemID != "radarr:412" || alert.ImageTag != "radarr" {
t.Errorf("artwork = %q/%q, want the radarr media cover", alert.ItemID, alert.ImageTag)
}
if alert.AiredAt != now.Format(time.RFC3339) {
t.Errorf("airedAt = %q, want the import time so it sorts with the rest", alert.AiredAt)
}
}
func TestRadarrImportAlertIgnoresEventsThatAreNotANewFilm(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
upgrade := importPayload(412, 9002, "Mr. Smith Goes to Washington", 1939)
upgrade.IsUpgrade = true
grab := importPayload(413, 0, "Some Film", 2024)
grab.EventType = "Grab"
untitled := importPayload(414, 9003, " ", 2024)
unknownMovie := importPayload(0, 9004, "No Id", 2024)
for name, payload := range map[string]radarrWebhookPayload{
"quality upgrade of a film already there": upgrade,
"grabbed but not imported": grab,
"no title": untitled,
"no movie id": unknownMovie,
} {
if _, ok := radarrImportAlert(payload, now); ok {
t.Errorf("%s: expected no alert", name)
}
}
}
func TestRadarrImportAlertAcceptsTheNewerEventName(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
payload := importPayload(415, 9005, "Rear Window", 1954)
payload.EventType = "MovieFileImported"
if _, ok := radarrImportAlert(payload, now); !ok {
t.Error("expected the alternate import event name to be announced")
}
}
// A file id is what makes a repeated notification the same news; without one the id
// falls back to the clock so a re-import is not silently swallowed.
func TestRadarrImportAlertWithoutAFileIDIsStillAnnounced(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
alert, ok := radarrImportAlert(importPayload(416, 0, "Sabotage", 1936), now)
if !ok {
t.Fatal("expected an alert")
}
if !strings.HasPrefix(alert.ID, "radarr:416:imported:") {
t.Errorf("alert id = %q, want a time-keyed fallback", alert.ID)
}
}
func TestAppendAlertPrunesExpiredAndDeduplicates(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
existing := []storedAlert{
{
Alert: clientAlert{ID: "radarr:1:file:1", AiredAt: now.Add(-4 * time.Hour).Format(time.RFC3339)},
ExpiresAt: now.Add(-time.Minute),
},
{
Alert: clientAlert{ID: "radarr:2:file:2", AiredAt: now.Add(-time.Hour).Format(time.RFC3339)},
ExpiresAt: now.Add(2 * time.Hour),
},
{
Alert: clientAlert{ID: "radarr:3:file:3", AiredAt: now.Add(-2 * time.Hour).Format(time.RFC3339)},
ExpiresAt: now.Add(time.Hour),
},
}
// Radarr delivering the same import twice must not stack two banners.
repeat := clientAlert{ID: "radarr:3:file:3", AiredAt: now.Format(time.RFC3339)}
stored := appendAlert(existing, repeat, now.Add(3*time.Hour), now)
if len(stored) != 2 {
t.Fatalf("expected 2 stored alerts, got %d: %+v", len(stored), stored)
}
if stored[0].Alert.ID != "radarr:3:file:3" {
t.Errorf("newest first: got %q", stored[0].Alert.ID)
}
if stored[1].Alert.ID != "radarr:2:file:2" {
t.Errorf("expected the unexpired older alert to survive, got %q", stored[1].Alert.ID)
}
}
func TestAppendAlertKeepsOnlyTheNewestFew(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
var stored []storedAlert
// A bulk import: many films land at once and every one of them is current.
for i := 0; i < maxStoredAlerts+5; i++ {
alert := clientAlert{
ID: "radarr:" + time.Duration(i).String(),
AiredAt: now.Add(time.Duration(i) * time.Minute).Format(time.RFC3339),
}
stored = appendAlert(stored, alert, now.Add(3*time.Hour), now)
}
if len(stored) != maxStoredAlerts {
t.Fatalf("stored %d alerts, want a cap of %d", len(stored), maxStoredAlerts)
}
}
func TestLiveAlertsDropsClosedWindows(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
alerts := liveAlerts([]storedAlert{
{Alert: clientAlert{ID: "current"}, ExpiresAt: now.Add(time.Minute)},
{Alert: clientAlert{ID: "stale"}, ExpiresAt: now.Add(-time.Second)},
}, now)
if len(alerts) != 1 || alerts[0].ID != "current" {
t.Fatalf("expected only the current alert, got %+v", alerts)
}
}
func TestMergeAlertsOrdersNewestFirstAcrossSources(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
at := func(d time.Duration) string { return now.Add(d).Format(time.RFC3339) }
merged := mergeAlerts(
[]clientAlert{{ID: "movie-recent", AiredAt: at(-10 * time.Minute)}},
[]clientAlert{
{ID: "episode-older", AiredAt: at(-2 * time.Hour)},
{ID: "episode-newest", AiredAt: at(-time.Minute)},
},
)
want := []string{"episode-newest", "movie-recent", "episode-older"}
for i, id := range want {
if merged[i].ID != id {
t.Fatalf("merged[%d] = %q, want %q (%+v)", i, merged[i].ID, id, merged)
}
}
}
func TestMergeAlertsCapsWhatOneTVIsShown(t *testing.T) {
now := time.Date(2026, 7, 31, 19, 4, 0, 0, time.UTC)
var many []clientAlert
for i := 0; i < maxAlerts+3; i++ {
many = append(many, clientAlert{
ID: "alert-" + time.Duration(i).String(),
AiredAt: now.Add(-time.Duration(i) * time.Minute).Format(time.RFC3339),
})
}
if got := len(mergeAlerts(many)); got != maxAlerts {
t.Fatalf("merged %d alerts, want a cap of %d", got, maxAlerts)
}
}
func TestWebhookTokenIsReadFromEveryFormRadarrCanSend(t *testing.T) {
for name, build := range map[string]func() *http.Request{
"header": func() *http.Request {
r := newWebhookRequest("/hooks/radarr")
r.Header.Set("X-Memby-Token", "secret")
return r
},
"bearer": func() *http.Request {
r := newWebhookRequest("/hooks/radarr")
r.Header.Set("Authorization", "Bearer secret")
return r
},
"basic auth": func() *http.Request {
r := newWebhookRequest("/hooks/radarr")
r.SetBasicAuth("memby", "secret")
return r
},
"query": func() *http.Request {
return newWebhookRequest("/hooks/radarr?token=secret")
},
} {
if got := webhookToken(build()); got != "secret" {
t.Errorf("%s: token = %q, want %q", name, got, "secret")
}
}
}
func newWebhookRequest(target string) *http.Request {
return httptest.NewRequest(http.MethodPost, target, strings.NewReader("{}"))
}
func TestRadarrWebhookIsHiddenUntilATokenIsConfigured(t *testing.T) {
s := &Server{cfg: config.Config{}, log: discardLogger()}
rec := httptest.NewRecorder()
s.handleRadarrWebhook(rec, newWebhookRequest("/hooks/radarr?token=anything"))
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404 for an unconfigured hook", rec.Code)
}
}
func TestRadarrWebhookRejectsAWrongToken(t *testing.T) {
s := &Server{
cfg: config.Config{RadarrWebhookToken: "hook-secret", RadarrAlertWindow: time.Hour},
log: discardLogger(),
}
rec := httptest.NewRecorder()
s.handleRadarrWebhook(rec, newWebhookRequest("/hooks/radarr?token=guess"))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("got %d, want 401", rec.Code)
}
}
// Radarr's Test button has to succeed without putting a film that does not exist on
// every television in the house.
func TestRadarrWebhookTestEventAnnouncesNothing(t *testing.T) {
s := &Server{
cfg: config.Config{RadarrWebhookToken: "hook-secret", RadarrAlertWindow: time.Hour},
log: discardLogger(),
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost, "/hooks/radarr?token=hook-secret",
strings.NewReader(`{"eventType":"Test","movie":{"id":1,"title":"Test Title"}}`),
)
// A nil cache would panic if this reached the store, which is the assertion.
s.handleRadarrWebhook(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"encoding/json"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/radarr"
)
func TestBuildRadarrRowUsesDigitalReleasesAndEstimatedCinemaFallbackInFiveDayWindow(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 30, 9, 0, 0, 0, location)
digitalToday := time.Date(2026, 7, 30, 0, 0, 0, 0, location).UTC()
digitalSunday := time.Date(2026, 8, 2, 0, 0, 0, 0, location).UTC()
outside := time.Date(2026, 8, 4, 0, 0, 0, 0, location).UTC()
theatricalOnly := time.Date(2026, 7, 1, 0, 0, 0, 0, location).UTC()
oldDigital := time.Date(1993, 4, 9, 0, 0, 0, 0, location).UTC()
modernRerelease := time.Date(2026, 7, 2, 0, 0, 0, 0, location).UTC()
row, err := buildRadarrRow([]radarr.Movie{
{ID: 1, Title: "Today", DigitalRelease: &digitalToday, Monitored: true},
{ID: 2, Title: "Sunday", DigitalRelease: &digitalSunday, Monitored: true},
{ID: 3, Title: "Outside", DigitalRelease: &outside, Monitored: true},
{ID: 4, Title: "Cinema Only", InCinemas: &theatricalOnly, Monitored: true},
{ID: 5, Title: "Old Digital Release", Year: 1993, DigitalRelease: &oldDigital, InCinemas: &modernRerelease, Monitored: true},
}, now, location)
if err != nil {
t.Fatal(err)
}
if row.ID != "radarr-upcoming-movies" || row.Kind != "movie-schedule" ||
row.Title != "Upcoming Movie releases" || len(row.Items) != 3 {
t.Fatalf("unexpected row: %+v", row)
}
var first, second radarrScheduleItem
if err := json.Unmarshal(row.Items[0], &first); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(row.Items[1], &second); err != nil {
t.Fatal(err)
}
if first.ID != "radarr:1" || first.MembyAirLabel != "Digital release today" {
t.Fatalf("unexpected first item: %+v", first)
}
if second.ID != "radarr:4" || second.MembyAirLabel != "Estimated digital release tomorrow" ||
second.MembyAvailabilityText != "Estimated digital release" {
t.Fatalf("unexpected second item: %+v", second)
}
var third radarrScheduleItem
if err := json.Unmarshal(row.Items[2], &third); err != nil {
t.Fatal(err)
}
if third.ID != "radarr:2" || third.MembyAirLabel != "Digital release Sunday" {
t.Fatalf("unexpected third item: %+v", third)
}
}
func TestRadarrScheduleItemIncludesArtworkAndDownloadState(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
release := time.Date(2026, 8, 1, 0, 0, 0, 0, location).UTC()
added := time.Date(2026, 7, 31, 22, 15, 0, 0, time.UTC)
movie := radarr.Movie{
ID: 9, Title: "Arrival", DigitalRelease: &release, HasFile: true, Monitored: true,
MovieFile: &radarr.MovieFile{DateAdded: &added},
Images: []radarr.Image{{CoverType: "poster"}, {CoverType: "fanart"}},
}
effective, ok := effectiveRadarrRelease(movie)
if !ok {
t.Fatal("expected a release date")
}
item := toRadarrScheduleItem(movie, effective, time.Date(2026, 7, 30, 9, 0, 0, 0, location), location)
if item.MembySource != "radarr" || item.MembyPlayable ||
item.ImageTags["Primary"] == "" || len(item.BackdropImageTags) != 1 {
t.Fatalf("unexpected synthetic item: %+v", item)
}
if item.MembyAvailability != "available" || item.MembyAvailabilityText != "Added at 10:15 AM" {
t.Fatalf("unexpected availability: %+v", item)
}
}
+553
View File
@@ -0,0 +1,553 @@
package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
func recommendationRow(id string) bool {
return id == "recommended" ||
strings.HasPrefix(id, "for-you:") ||
strings.HasPrefix(id, "curated:") ||
strings.HasPrefix(id, "similar:")
}
// filterRecommendationPermissions makes Emby, using this viewer's token, the final
// eligibility authority. The shared imported catalogue can suggest candidates but can
// never broaden library access or bypass parental controls.
func (s *Server) filterRecommendationPermissions(
ctx context.Context,
sess store.Session,
rows []recommend.Row,
) []recommend.Row {
ids := []string{}
for _, row := range rows {
if !recommendationRow(row.ID) {
continue
}
for _, item := range recommend.Decode(row.Items) {
ids = append(ids, item.ID)
}
}
if len(ids) == 0 {
return rows
}
allowed := map[string]bool{}
cred := credentials(sess)
for start := 0; start < len(ids); start += 100 {
end := min(start+100, len(ids))
result, err := s.emby.Items(ctx, cred, rowParams(url.Values{
"Ids": {strings.Join(ids[start:end], ",")},
"Recursive": {"true"},
"IncludeItemTypes": {"Movie,Series"},
"Limit": {itoa(end - start)},
}, fieldsRow))
if err != nil {
s.log.Warn("recommendation permission check failed; hiding candidates",
"user", sess.EmbyUserID, "error", err)
for index := range rows {
if recommendationRow(rows[index].ID) {
rows[index].Items = []json.RawMessage{}
}
}
return rows
}
for _, raw := range result.Items {
decoded := recommend.Decode([]json.RawMessage{raw})
if len(decoded) == 1 {
allowed[decoded[0].ID] = true
}
}
}
for index := range rows {
if !recommendationRow(rows[index].ID) {
continue
}
filtered := []json.RawMessage{}
for _, item := range recommend.Decode(rows[index].Items) {
if allowed[item.ID] {
filtered = append(filtered, item.Raw)
}
}
rows[index].Items = filtered
}
return rows
}
func (s *Server) rankingContext(
ctx context.Context,
userID string,
) (recommend.WeightedProfile, map[string]recommend.ItemExposure, map[string]float64) {
profile := recommend.WeightedProfile{}
if s.store == nil {
return profile, nil, nil
}
if raw, err := s.store.WeightedRecommendationProfile(ctx, userID); err == nil {
_ = json.Unmarshal(raw, &profile)
} else {
s.log.Warn("weighted profile unavailable", "user", userID, "error", err)
}
if profile.ExplicitPositive == nil {
profile.ExplicitPositive = map[string]bool{}
}
if profile.ExplicitNegative == nil {
profile.ExplicitNegative = map[string]bool{}
}
if raw, err := s.store.RecommendationOnboarding(ctx, userID); err == nil {
var preferences recommend.OnboardingPreferences
if json.Unmarshal(raw, &preferences) == nil {
profile.ApplyOnboarding(preferences, s.weightedConfig().MinimumEvidence)
ids := make([]string, 0, len(preferences.Ratings))
for id, rating := range preferences.Ratings {
if strings.TrimSpace(id) != "" && rating >= 1 && rating <= 5 {
ids = append(ids, id)
}
}
if raws, itemErr := s.store.LibraryItemsByID(ctx, ids); itemErr == nil {
for _, item := range recommend.Decode(raws) {
profile.ApplyOnboardingRating(
item, preferences.Ratings[item.ID],
s.weightedConfig().MinimumEvidence,
)
}
}
}
}
if actions, err := s.store.RecommendationActions(ctx, userID); err == nil {
ids := make([]string, 0, len(actions))
byID := make(map[string]string, len(actions))
for _, action := range actions {
ids = append(ids, action.ItemID)
byID[action.ItemID] = action.Action
switch action.Action {
case "more_like_this":
profile.ExplicitPositive[action.ItemID] = true
case "not_for_me":
profile.ExplicitNegative[action.ItemID] = true
}
}
if raws, itemErr := s.store.LibraryItemsByID(ctx, ids); itemErr == nil {
for _, item := range recommend.Decode(raws) {
profile.ApplyExplicitPreference(item, byID[item.ID] == "more_like_this")
}
}
}
exposures := map[string]recommend.ItemExposure{}
if values, err := s.store.UserItemExposures(
ctx, userID, time.Now().Add(-45*24*time.Hour),
); err == nil {
for _, value := range values {
exposures[value.ItemID] = recommend.ItemExposure{
Impressions: value.Impressions, Focuses: value.Focuses,
Selects: value.Selects, LastShown: value.LastShown,
}
}
}
household, err := s.store.HouseholdCompletionScores(ctx, time.Now().Add(-180*24*time.Hour))
if err != nil {
household = map[string]float64{}
}
return profile, exposures, household
}
func (s *Server) personalizeTitles(
ctx context.Context,
sess store.Session,
rows []recommend.Row,
) []recommend.Row {
if len(rows) == 0 {
return rows
}
profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID)
cfg := s.weightedConfig()
now := time.Now()
location := s.cfg.SonarrLocation
for index := range rows {
row := &rows[index]
compatibility := map[string]float64{}
for _, raw := range row.Items {
var marker struct {
ID string `json:"Id"`
Compatibility string `json:"MembyCompatibility"`
}
if json.Unmarshal(raw, &marker) == nil {
switch {
case strings.Contains(strings.ToLower(marker.Compatibility), "direct"):
compatibility[marker.ID] = 1
case strings.Contains(strings.ToLower(marker.Compatibility), "transcod"):
compatibility[marker.ID] = -1
}
}
}
intent := recommend.RankIntent{
ID: row.ID, Now: now, Location: location, HouseholdScores: household,
Compatibility: compatibility,
}
switch {
case row.ID == "latest-movies":
intent.NewReleasesOnly = true
case strings.Contains(row.ID, "one-episode"), strings.Contains(row.ID, "late-night"):
intent.PreferShort = true
intent.MaxRuntimeMins = 60
case strings.Contains(row.ID, "hidden"):
intent.HiddenLibrary = true
intent.UnseenOnly = true
}
ranked := recommend.WeightedRank(
profile, recommend.Decode(row.Items), exposures, intent, cfg, len(row.Items),
)
items := make([]json.RawMessage, 0, len(ranked))
for _, value := range ranked {
items = append(items, recommend.EnrichRankedItem(value))
}
// Mandatory progress rows must remain useful even before a profile is prepared.
if len(items) > 0 || row.ID != "continue" && row.ID != "next-up" {
row.Items = items
}
}
return rows
}
func (s *Server) personalizeSearch(
ctx context.Context,
sess store.Session,
term string,
raws []json.RawMessage,
limit int,
) []json.RawMessage {
profile, exposures, household := s.rankingContext(ctx, sess.EmbyUserID)
items := recommend.Decode(raws)
relevance := make(map[string]float64, len(items))
wanted := strings.ToLower(strings.TrimSpace(term))
for _, item := range items {
name := strings.ToLower(strings.TrimSpace(item.Name))
switch {
case name == wanted:
relevance[item.ID] = 20
case strings.HasPrefix(name, wanted):
relevance[item.ID] = 12
case strings.Contains(name, wanted):
relevance[item.ID] = 8
default:
relevance[item.ID] = 4
}
}
ranked := recommend.WeightedRank(profile, items, exposures, recommend.RankIntent{
ID: "search", Now: time.Now(), Location: s.cfg.SonarrLocation,
SearchRelevance: relevance, HouseholdScores: household,
}, s.weightedConfig(), limit)
out := make([]json.RawMessage, 0, len(ranked))
for _, value := range ranked {
out = append(out, recommend.EnrichRankedItem(value))
}
return out
}
func (s *Server) weightedConfig() recommend.WeightedConfig {
cfg := recommend.DefaultWeightedConfig()
if s.cfg.RecommendationWeights != "" {
_ = json.Unmarshal([]byte(s.cfg.RecommendationWeights), &cfg)
}
return cfg
}
// deduplicateRows gives the earliest row ownership of a title. Continue Watching and
// Next Up keep their landmarks; later discovery shelves fill with their remaining
// unique posters.
func deduplicateRows(rows []recommend.Row) []recommend.Row {
seen := map[string]bool{}
for rowIndex := range rows {
items := recommend.Decode(rows[rowIndex].Items)
filtered := make([]json.RawMessage, 0, len(items))
for _, item := range items {
key := item.ID
if item.SeriesID != "" {
key = item.SeriesID
}
if key == "" || seen[key] {
continue
}
seen[key] = true
filtered = append(filtered, item.Raw)
}
rows[rowIndex].Items = filtered
}
return rows
}
// personalizeRowsByTitleScores uses the same title scores to order discovery shelves.
// Mandatory shelves receive stable anchors; all other rows compete on the average of
// their leading posters, which makes row ordering change with the same profile evidence
// that changes poster ordering.
func personalizeRowsByTitleScores(rows []recommend.Row) []recommend.Row {
type scoredRow struct {
row recommend.Row
score float64
position int
}
ranked := make([]scoredRow, 0, len(rows))
for position, row := range rows {
score := 0.0
count := 0
for _, raw := range row.Items {
var payload struct {
Score float64 `json:"MembyRecommendationScore"`
}
if json.Unmarshal(raw, &payload) == nil {
score += payload.Score
count++
}
if count == 6 {
break
}
}
if count > 0 {
score /= float64(count)
}
// A small authored-position prior avoids reshuffling ties and cold starts.
score += 0.05 / float64(position+1)
switch row.ID {
case "continue":
score = 1_000
case "next-up":
score = 100
case "latest-movies":
score = 90
}
ranked = append(ranked, scoredRow{row: row, score: score, position: position})
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].score != ranked[j].score {
return ranked[i].score > ranked[j].score
}
return ranked[i].position < ranked[j].position
})
out := make([]recommend.Row, 0, len(ranked))
for _, value := range ranked {
out = append(out, value.row)
}
return out
}
func selectPersonalizedRows(rows []recommend.Row) []recommend.Row {
out := make([]recommend.Row, 0, len(rows))
for _, row := range rows {
switch row.ID {
case "continue", "next-up", "latest-movies", "favorites":
out = append(out, row)
continue
}
if len(row.Items) == 0 {
continue
}
total, count := 0.0, 0
for _, raw := range row.Items {
var payload struct {
Score float64 `json:"MembyRecommendationScore"`
}
if json.Unmarshal(raw, &payload) == nil {
total += payload.Score
count++
}
if count == 6 {
break
}
}
if count > 0 && total/float64(count) < -0.25 {
continue
}
out = append(out, row)
}
return out
}
type recommendationActionRequest struct {
Action string `json:"action"`
}
func (s *Server) handleRecommendationAction(
w http.ResponseWriter,
r *http.Request,
sess store.Session,
) {
itemID := strings.TrimSpace(r.PathValue("id"))
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
var err error
if r.Method == http.MethodDelete {
err = s.store.ClearRecommendationAction(r.Context(), sess.EmbyUserID, itemID)
} else {
var req recommendationActionRequest
if decodeErr := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req); decodeErr != nil {
writeError(w, http.StatusBadRequest, "invalid recommendation action")
return
}
err = s.store.SetRecommendationAction(
r.Context(), sess.EmbyUserID, itemID, strings.TrimSpace(req.Action),
)
}
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
if s.forYou != nil {
s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess)
s.forYou.RefreshAsync(sess, false)
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleRecommendationPreferences(
w http.ResponseWriter,
r *http.Request,
sess store.Session,
) {
if r.Method == http.MethodGet {
s.handleRecommendationPreferencesGet(w, r, sess)
return
}
var preferences recommend.OnboardingPreferences
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&preferences); err != nil {
writeError(w, http.StatusBadRequest, "invalid onboarding preferences")
return
}
if len(preferences.Ratings) > 40 {
writeError(w, http.StatusBadRequest, "too many onboarding ratings")
return
}
for id, rating := range preferences.Ratings {
if strings.TrimSpace(id) == "" || rating < 1 || rating > 5 {
writeError(w, http.StatusBadRequest, "ratings must be between 1 and 5")
return
}
}
preferences.Completed = true
raw, _ := json.Marshal(preferences)
if err := s.store.SetRecommendationOnboarding(
r.Context(), sess.EmbyUserID, raw,
); err != nil {
writeError(w, http.StatusInternalServerError, "could not save onboarding preferences")
return
}
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
if s.forYou != nil {
s.forYou.MarkDirty(context.WithoutCancel(r.Context()), sess)
s.forYou.RefreshAsync(sess, false)
}
w.WriteHeader(http.StatusNoContent)
}
type recommendationOnboardingResponse struct {
Completed bool `json:"completed"`
Ratings map[string]int `json:"ratings"`
Items []json.RawMessage `json:"items"`
}
func (s *Server) handleRecommendationPreferencesGet(
w http.ResponseWriter,
r *http.Request,
sess store.Session,
) {
preferences := recommend.OnboardingPreferences{}
if raw, err := s.store.RecommendationOnboarding(r.Context(), sess.EmbyUserID); err == nil {
_ = json.Unmarshal(raw, &preferences)
}
if preferences.Ratings == nil {
preferences.Ratings = map[string]int{}
}
if preferences.Completed {
writeJSON(w, http.StatusOK, recommendationOnboardingResponse{
Completed: true, Ratings: preferences.Ratings, Items: []json.RawMessage{},
})
return
}
raws, err := s.store.AllRecommendationCandidates(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load rating choices")
return
}
candidates := recommendationOnboardingCandidates(recommend.Decode(raws), 24)
row := recommend.Row{ID: "for-you:onboarding", Kind: "for-you"}
for _, item := range candidates {
row.Items = append(row.Items, item.Raw)
}
filtered := s.filterRecommendationPermissions(
r.Context(), sess, []recommend.Row{row},
)
items := []json.RawMessage{}
if len(filtered) == 1 {
items = filtered[0].Items
if len(items) > 16 {
items = items[:16]
}
}
writeJSON(w, http.StatusOK, recommendationOnboardingResponse{
Completed: preferences.Completed,
Ratings: preferences.Ratings,
Items: items,
})
}
// recommendationOnboardingCandidates selects recognisable, well-rated titles while
// keeping movies, series and primary genres mixed. It is deterministic so returning to
// an unfinished onboarding screen does not reshuffle the choices.
func recommendationOnboardingCandidates(items []recommend.Item, limit int) []recommend.Item {
sort.SliceStable(items, func(i, j int) bool {
if items[i].CommunityRating != items[j].CommunityRating {
return items[i].CommunityRating > items[j].CommunityRating
}
if items[i].ProductionYear != items[j].ProductionYear {
return items[i].ProductionYear > items[j].ProductionYear
}
return items[i].Name < items[j].Name
})
buckets := map[string][]recommend.Item{"movie": {}, "series": {}}
typeCounts := map[string]int{}
genreCounts := map[string]int{}
perType := max(1, limit/2)
for _, item := range items {
kind := strings.ToLower(strings.TrimSpace(item.Type))
if kind != "movie" && kind != "series" || item.CommunityRating <= 0 ||
typeCounts[kind] >= perType {
continue
}
genre := ""
if len(item.Genres) > 0 {
genre = strings.ToLower(strings.TrimSpace(item.Genres[0]))
}
if genre != "" && genreCounts[genre] >= 3 {
continue
}
buckets[kind] = append(buckets[kind], item)
typeCounts[kind]++
genreCounts[genre]++
if typeCounts["movie"]+typeCounts["series"] == limit {
break
}
}
out := make([]recommend.Item, 0, limit)
for index := 0; len(out) < limit; index++ {
added := false
for _, kind := range []string{"movie", "series"} {
if index < len(buckets[kind]) {
out = append(out, buckets[kind][index])
added = true
}
}
if !added {
break
}
}
return out
}
+7 -1
View File
@@ -56,7 +56,7 @@ func (s *Server) cachedRecommendations(ctx context.Context, userID string) []rec
// buildRecommendations computes and caches rows for one user.
func (s *Server) buildRecommendations(ctx context.Context, sess store.Session) ([]recommend.Row, error) {
rows, err := s.recommender.BuildRows(ctx, credentials(sess))
rows, err := s.recommender.BuildRowsForUser(ctx, credentials(sess), sess.Username)
if err != nil {
return nil, err
}
@@ -135,6 +135,9 @@ func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request, sess store
s.log.Warn("prepared For You read failed; using live fallback",
"user", sess.EmbyUserID, "error", err)
} else if hit {
rows = s.filterRecommendationPermissions(r.Context(), sess, rows)
rows = s.personalizeTitles(r.Context(), sess, rows)
rows = deduplicateRows(personalizeRowsByTitleScores(selectPersonalizedRows(rows)))
w.Header().Set("X-Memby-For-You", "prepared")
if stale {
s.forYou.MarkDirty(r.Context(), sess)
@@ -160,6 +163,9 @@ func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request, sess store
s.writeUpstreamError(w, err, "could not build For You recommendations")
return
}
rows = s.filterRecommendationPermissions(r.Context(), sess, rows)
rows = s.personalizeTitles(r.Context(), sess, rows)
rows = deduplicateRows(personalizeRowsByTitleScores(selectPersonalizedRows(rows)))
w.Header().Set("X-Memby-For-You", "live-fallback")
writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)})
}
+86
View File
@@ -0,0 +1,86 @@
package api
import (
"encoding/json"
"net/http"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
// fieldsRelated adds Studios to the detail set: the explanation layer names the studio a
// viewer keeps returning to, and Emby omits it unless asked.
const fieldsRelated = fieldsDetail + ",Studios"
// relatedResponse is what the detail page renders: a strip of short reasons under the
// description, and the carousel beneath the page.
type relatedResponse struct {
Reasons []string `json:"reasons"`
Items []json.RawMessage `json:"items"`
}
// handleRelated explains one title to one viewer and lists what resembles it.
//
// Building the taste profile costs the same Emby fan-out the home rows pay for, so the
// whole answer is cached per user and item. It is deliberately *not* folded into
// `/v1/items/{id}`: that response is shared with the screensaver and the player, and this
// one is only ever needed once a detail page is open.
func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "related:v1:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
writeRaw(w, http.StatusOK, raw)
return
}
raw, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsRelated)
if err != nil {
s.writeUpstreamError(w, err, "could not load the item")
return
}
decoded := recommend.Decode([]json.RawMessage{raw})
if len(decoded) == 0 {
writeError(w, http.StatusNotFound, "item not found")
return
}
reasons, related, err := s.recommender.RelatedTo(
ctx, credentials(sess), decoded[0], relatedRowSize,
)
if err != nil {
s.writeUpstreamError(w, err, "could not load related titles")
return
}
body, err := json.Marshal(relatedResponse{
Reasons: nonNilStrings(reasons),
Items: nonNilRaws(recommend.Raws(related)),
})
if err != nil {
writeError(w, http.StatusInternalServerError, "could not encode related titles")
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil {
s.log.Warn("related cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
// relatedRowSize is a carousel's worth. The strip scrolls, but a viewer who reaches the
// twelfth card has stopped looking for something like this one.
const relatedRowSize = 12
func nonNilRaws(values []json.RawMessage) []json.RawMessage {
if values == nil {
return []json.RawMessage{}
}
return values
}
+46
View File
@@ -0,0 +1,46 @@
package api
import (
"encoding/json"
"strings"
"testing"
)
// The TV decodes this into GatewayRelated. Both field names and the empty-value encoding
// are part of the contract: kotlinx.serialization would reject a null where it expects a
// list, so a title with no reasons must still send `[]`.
func TestRelatedResponseWireShape(t *testing.T) {
raw, err := json.Marshal(relatedResponse{
Reasons: nonNilStrings(nil),
Items: nonNilRaws(nil),
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if got := string(raw); got != `{"reasons":[],"items":[]}` {
t.Fatalf("empty response encoded as %s", got)
}
raw, err = json.Marshal(relatedResponse{
Reasons: []string{"Because you watch Thriller"},
Items: []json.RawMessage{json.RawMessage(`{"Id":"1","Name":"Sicario"}`)},
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Items are Emby's item JSON forwarded verbatim, exactly as every other row is.
if !strings.Contains(string(raw), `"items":[{"Id":"1","Name":"Sicario"}]`) {
t.Fatalf("item payload was not forwarded verbatim: %s", raw)
}
}
func TestRelatedFieldsAskEmbyForStudios(t *testing.T) {
// Why() names the studio a viewer keeps returning to, and Emby omits Studios unless
// the request asks for it.
if !strings.Contains(fieldsRelated, "Studios") {
t.Fatalf("fieldsRelated must request Studios, got %q", fieldsRelated)
}
if !strings.Contains(fieldsRelated, "People") {
t.Fatalf("fieldsRelated must request People, got %q", fieldsRelated)
}
}
+241 -22
View File
@@ -1,8 +1,13 @@
package api
import (
"archive/zip"
"crypto/sha256"
"crypto/subtle"
_ "embed"
"encoding/hex"
"fmt"
"html/template"
"io"
"net/http"
"os"
@@ -15,6 +20,11 @@ import (
const maxReleaseSize = 250 << 20
//go:embed install.html
var installPageSource string
var installPage = template.Must(template.New("install").Parse(installPageSource))
var (
releaseVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
releaseFilenamePattern = regexp.MustCompile(`^memby-\d+\.\d+\.\d+\.apk$`)
@@ -51,6 +61,11 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "version must look like 0.1.54")
return
}
mandatory, validMandatory := parseMandatoryRelease(r.FormValue("mandatory"))
if !validMandatory {
writeError(w, http.StatusBadRequest, "mandatory must be true or false")
return
}
current := s.updatePolicy.get()
if current.LatestVersion != "" &&
@@ -80,35 +95,67 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
tempName := temp.Name()
defer os.Remove(tempName)
written, copyErr := io.Copy(temp, source)
digest := sha256.New()
written, copyErr := io.Copy(io.MultiWriter(temp, digest), source)
syncErr := temp.Sync()
closeErr := temp.Close()
if copyErr != nil || closeErr != nil || written < 4 {
writeError(w, http.StatusBadRequest, "could not store APK")
return
}
// APKs are ZIP archives. This catches accidentally uploaded logs or HTML error pages
// before they become an update every TV is invited to install.
stored, err := os.Open(tempName)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not verify APK")
if syncErr != nil {
writeError(w, http.StatusInternalServerError, "could not safely store APK")
return
}
var magic [4]byte
_, readErr := io.ReadFull(stored, magic[:])
stored.Close()
if readErr != nil || string(magic[:2]) != "PK" {
actualSHA256 := hex.EncodeToString(digest.Sum(nil))
if expected := strings.ToLower(strings.TrimSpace(r.FormValue("sha256"))); expected != "" &&
(expected != actualSHA256 || len(expected) != sha256.Size*2) {
writeError(w, http.StatusBadRequest, "APK checksum does not match")
return
}
// Parse the archive, rather than checking only its first two bytes. AndroidManifest.xml
// is compulsory in an APK; this rejects truncated ZIPs and renamed logs/HTML.
archive, err := zip.OpenReader(tempName)
if err != nil {
writeError(w, http.StatusBadRequest, "uploaded file is not an APK")
return
}
hasManifest := false
for _, entry := range archive.File {
if entry.Name == "AndroidManifest.xml" {
hasManifest = true
break
}
}
archive.Close()
if !hasManifest {
writeError(w, http.StatusBadRequest, "uploaded APK has no Android manifest")
return
}
filename := fmt.Sprintf("memby-%s.apk", version)
destination := filepath.Join(s.cfg.ReleaseDir, filename)
if err := os.Rename(tempName, destination); err != nil {
s.log.Error("release publish rename failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not publish APK")
newFile := true
if existingSHA256, hashErr := fileSHA256(destination); hashErr == nil {
if existingSHA256 != actualSHA256 {
writeError(w, http.StatusConflict,
"that version already exists with different APK contents; publish a new version")
return
}
newFile = false
} else if !os.IsNotExist(hashErr) {
s.log.Error("existing release could not be verified", "error", hashErr)
writeError(w, http.StatusInternalServerError, "could not verify existing release")
return
}
if newFile {
if err := os.Rename(tempName, destination); err != nil {
s.log.Error("release publish rename failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not publish APK")
return
}
}
if err := os.Chmod(destination, 0o640); err != nil {
s.log.Warn("release permissions could not be tightened", "error", err)
}
@@ -117,10 +164,20 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
Enabled: true,
LatestVersion: version,
MinimumVersion: current.MinimumVersion,
DownloadURL: s.cfg.PublicURL + "/updates/" + filename,
DownloadURL: s.cfg.PublicURL + s.signedReleasePath(filename),
SHA256: actualSHA256,
SizeBytes: written,
Notes: strings.TrimSpace(r.FormValue("notes")),
}
if mandatory {
// Setting the floor to the release being published makes every older client
// receive a mandatory verdict, which has no dismiss/skip path on the TV.
policy.MinimumVersion = version
}
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
if newFile {
_ = os.Remove(destination)
}
s.log.Error("release policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be saved")
return
@@ -131,19 +188,181 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
return
}
s.log.Info("release published", "version", version, "bytes", written, "file", filename)
s.log.Info("release published", "version", version, "mandatory", mandatory,
"bytes", written, "file", filename)
writeJSON(w, http.StatusCreated, s.updatePolicy.get())
}
// handleReleaseDownload serves immutable, signed APKs. They carry no household secrets,
// so downloads do not need a TV session and continue working through Android's installer.
func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) {
filename := r.PathValue("filename")
if !releaseFilenamePattern.MatchString(filename) {
func parseMandatoryRelease(value string) (mandatory, valid bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "0", "false":
return false, true
case "1", "true":
return true, true
default:
return false, false
}
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
digest := sha256.New()
if _, err := io.Copy(digest, file); err != nil {
return "", err
}
return hex.EncodeToString(digest.Sum(nil)), nil
}
type installPageData struct {
Authenticated bool
Ready bool
Version string
Notes string
DownloadURL string
Size string
Error string
LoginNext string
}
func preventDiscovery(w http.ResponseWriter) {
// These cover general search engines, crawler-specific implementations and caches.
// They are intentionally also applied to APK responses so a discovered download URL
// does not appear as a searchable binary result.
w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive, nosnippet, noimageindex")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
}
func handleRobots(w http.ResponseWriter, _ *http.Request) {
preventDiscovery(w)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=86400")
_, _ = io.WriteString(w, "User-agent: *\nDisallow: /\n")
}
// handleInstallPage is deliberately public: it is the bootstrap path for a television
// that does not have Memby yet. It exposes only the signed APK and operator-authored
// release notes, never household or Emby data.
func (s *Server) handleInstallPage(w http.ResponseWriter, r *http.Request) {
s.renderInstallPage(w, r, "", http.StatusOK)
}
func (s *Server) renderInstallPage(
w http.ResponseWriter,
r *http.Request,
message string,
status int,
) {
if len(s.installerSecret()) == 0 {
http.NotFound(w, r)
return
}
authenticated := s.validInstallerSession(r)
if !authenticated {
s.renderAccessLogin(w, r, message, status, "/install")
return
}
policy := s.updatePolicy.get()
version := strings.TrimSpace(policy.LatestVersion)
filename := fmt.Sprintf("memby-%s.apk", version)
info, err := os.Stat(filepath.Join(s.cfg.ReleaseDir, filename))
ready := authenticated && releaseVersionPattern.MatchString(version) &&
err == nil && !info.IsDir()
data := installPageData{
Authenticated: true,
Ready: ready,
Version: version,
Notes: strings.TrimSpace(policy.Notes),
Error: message,
}
if ready {
data.DownloadURL = "/updates/latest.apk"
data.Size = fmt.Sprintf("%.1f MB", float64(info.Size())/(1024*1024))
}
s.writeInstallPage(w, data, status)
}
func (s *Server) renderAccessLogin(
w http.ResponseWriter,
r *http.Request,
message string,
status int,
next string,
) {
if len(s.installerSecret()) == 0 {
http.NotFound(w, r)
return
}
s.writeInstallPage(w, installPageData{
Error: message,
LoginNext: cleanInstallerDestination(next),
}, status)
}
func (s *Server) writeInstallPage(w http.ResponseWriter, data installPageData, status int) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; "+
"base-uri 'none'; frame-ancestors 'none'")
w.Header().Set("Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()")
preventDiscovery(w)
if status == http.StatusOK && data.Authenticated && !data.Ready {
status = http.StatusServiceUnavailable
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if err := installPage.Execute(w, data); err != nil && s.log != nil {
s.log.Error("install page render failed", "error", err)
}
}
// handleLatestReleaseDownload gives first-time installers a stable address. Serve the
// package directly: some Android TV downloaders hand both sides of an HTTP redirect to
// the package installer, causing a successful install followed by a spurious parse error.
func (s *Server) handleLatestReleaseDownload(w http.ResponseWriter, r *http.Request) {
if !s.validInstallerSession(r) {
http.NotFound(w, r)
return
}
version := strings.TrimSpace(s.updatePolicy.get().LatestVersion)
if !releaseVersionPattern.MatchString(version) {
http.NotFound(w, r)
return
}
filename := fmt.Sprintf("memby-%s.apk", version)
if info, err := os.Stat(filepath.Join(s.cfg.ReleaseDir, filename)); err != nil || info.IsDir() {
http.NotFound(w, r)
return
}
preventDiscovery(w)
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
w.Header().Set("Cache-Control", "private, no-store")
http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename))
}
// handleReleaseDownload serves immutable, signed APKs. Access requires either a short
// browser installer session or the signed release URL returned to an authenticated app.
func (s *Server) handleReleaseDownload(w http.ResponseWriter, r *http.Request) {
filename := r.PathValue("filename")
if !releaseFilenamePattern.MatchString(filename) ||
!s.allowedReleaseDownload(r, filename) {
// A 404 does not confirm whether a guessed version exists.
http.NotFound(w, r)
return
}
preventDiscovery(w)
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
w.Header().Set("Cache-Control", "private, no-store")
http.ServeFile(w, r, filepath.Join(s.cfg.ReleaseDir, filename))
}
+304 -2
View File
@@ -1,13 +1,23 @@
package api
import (
"crypto/sha256"
"encoding/hex"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
)
func TestReleasePublishAuth(t *testing.T) {
@@ -43,15 +53,25 @@ func TestReleaseDownloadOnlyServesVersionedAPKs(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "memby-0.1.54.apk"), payload, 0o600); err != nil {
t.Fatal(err)
}
s := &Server{cfg: config.Config{ReleaseDir: dir}}
s := &Server{cfg: config.Config{
ReleaseDir: dir, ReleasePublishToken: "test-release-secret",
}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/updates/memby-0.1.54.apk", nil)
req := httptest.NewRequest(http.MethodGet,
s.signedReleasePath("memby-0.1.54.apk"), nil)
req.SetPathValue("filename", "memby-0.1.54.apk")
s.handleReleaseDownload(rec, req)
if rec.Code != http.StatusOK || rec.Body.String() != string(payload) {
t.Fatalf("valid release response = %d %q", rec.Code, rec.Body.String())
}
if disposition := rec.Header().Get("Content-Disposition"); disposition !=
`attachment; filename="memby-0.1.54.apk"` {
t.Fatalf("Content-Disposition = %q", disposition)
}
if !strings.Contains(rec.Header().Get("X-Robots-Tag"), "noindex") {
t.Fatalf("APK crawler policy = %q", rec.Header().Get("X-Robots-Tag"))
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/updates/../secrets", nil)
@@ -61,3 +81,285 @@ func TestReleaseDownloadOnlyServesVersionedAPKs(t *testing.T) {
t.Fatalf("invalid filename got %d, want 404", rec.Code)
}
}
func TestInstallPageAndLatestDownloadUsePublishedRelease(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "memby-0.1.72.apk"), []byte("apk"), 0o600); err != nil {
t.Fatal(err)
}
s := &Server{cfg: config.Config{
ReleaseDir: dir, ReleasePublishToken: "test-release-secret",
}}
s.updatePolicy.set(appupdate.Policy{
LatestVersion: "0.1.72",
Notes: `<script>alert("no")</script>`,
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/install", nil)
addInstallerSession(t, s, req)
s.handleInstallPage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("install page status = %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "Download Memby 0.1.72") ||
!strings.Contains(body, `href="/updates/latest.apk"`) {
t.Fatalf("install page is missing current release details: %s", body)
}
if strings.Contains(body, `<script>alert`) {
t.Fatal("release notes were rendered without HTML escaping")
}
if rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("install page cache policy = %q", rec.Header().Get("Cache-Control"))
}
if robots := rec.Header().Get("X-Robots-Tag"); !strings.Contains(robots, "noindex") ||
!strings.Contains(robots, "nofollow") {
t.Fatalf("install page crawler policy = %q", robots)
}
if !strings.Contains(body, `name="robots" content="noindex,nofollow`) {
t.Fatal("install page has no crawler metadata")
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/updates/latest.apk", nil)
addInstallerSession(t, s, req)
s.handleLatestReleaseDownload(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("latest APK status = %d", rec.Code)
}
if location := rec.Header().Get("Location"); location != "" {
t.Fatalf("latest APK unexpectedly redirected to %q", location)
}
if contentType := rec.Header().Get("Content-Type"); contentType != "application/vnd.android.package-archive" {
t.Fatalf("latest APK content type = %q", contentType)
}
if disposition := rec.Header().Get("Content-Disposition"); disposition != `attachment; filename="memby-0.1.72.apk"` {
t.Fatalf("latest APK disposition = %q", disposition)
}
if rec.Body.String() != "apk" {
t.Fatalf("latest APK body = %q", rec.Body.String())
}
}
func TestRobotsDisallowsEntireHost(t *testing.T) {
rec := httptest.NewRecorder()
handleRobots(rec, httptest.NewRequest(http.MethodGet, "/robots.txt", nil))
if rec.Code != http.StatusOK {
t.Fatalf("robots status = %d", rec.Code)
}
if got := rec.Body.String(); got != "User-agent: *\nDisallow: /\n" {
t.Fatalf("robots body = %q", got)
}
if !strings.Contains(rec.Header().Get("X-Robots-Tag"), "noindex") {
t.Fatalf("robots response crawler policy = %q", rec.Header().Get("X-Robots-Tag"))
}
}
func TestInstallPageIsUnavailableWithoutPublishedAPK(t *testing.T) {
s := &Server{cfg: config.Config{
ReleaseDir: t.TempDir(), ReleasePublishToken: "test-release-secret",
}}
s.updatePolicy.set(appupdate.Policy{LatestVersion: "0.1.72"})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/install", nil)
addInstallerSession(t, s, req)
s.handleInstallPage(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("install page status = %d, want 503", rec.Code)
}
if !strings.Contains(rec.Body.String(), "installer is not available yet") {
t.Fatalf("unavailable page gave no useful message: %s", rec.Body.String())
}
}
func TestInstallerHidesReleaseUntilEmbyAuthenticated(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "memby-0.1.72.apk"), []byte("apk"), 0o600); err != nil {
t.Fatal(err)
}
s := &Server{cfg: config.Config{
ReleaseDir: dir, ReleasePublishToken: "test-release-secret",
}}
s.updatePolicy.set(appupdate.Policy{LatestVersion: "0.1.72"})
rec := httptest.NewRecorder()
s.handleInstallPage(rec, httptest.NewRequest(http.MethodGet, "/install", nil))
if rec.Code != http.StatusOK {
t.Fatalf("login page status = %d", rec.Code)
}
body := rec.Body.String()
lowerBody := strings.ToLower(body)
if !strings.Contains(body, "Username") || !strings.Contains(body, "Password") ||
strings.Contains(lowerBody, "memby") || strings.Contains(lowerBody, "emby") ||
strings.Contains(lowerBody, "installer") || strings.Contains(body, "0.1.72") {
t.Fatalf("unauthenticated page leaked release access: %s", body)
}
rec = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/updates/latest.apk", nil)
s.handleLatestReleaseDownload(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("unauthenticated latest APK status = %d, want 404", rec.Code)
}
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/updates/memby-0.1.72.apk", nil)
req.SetPathValue("filename", "memby-0.1.72.apk")
s.handleReleaseDownload(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("unsigned APK URL status = %d, want 404", rec.Code)
}
}
func TestInstallerIsHiddenWhenReleaseSecretIsUnconfigured(t *testing.T) {
s := &Server{cfg: config.Config{ReleaseDir: t.TempDir()}}
rec := httptest.NewRecorder()
s.handleInstallPage(rec, httptest.NewRequest(http.MethodGet, "/install", nil))
if rec.Code != http.StatusNotFound {
t.Fatalf("unconfigured installer status = %d, want 404", rec.Code)
}
}
func TestInstallerLoginDoesNotAuthenticateWithoutDeviceCleanupCredential(t *testing.T) {
s := &Server{
cfg: config.Config{ReleasePublishToken: "test-release-secret"},
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
form := url.Values{"username": {"Matt"}, "password": {"password"}}
req := httptest.NewRequest(http.MethodPost, "/install/login",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
s.handleInstallLogin(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("installer login status = %d, want 503", rec.Code)
}
if len(rec.Result().Cookies()) != 0 {
t.Fatal("installer login issued a session without a device cleanup credential")
}
}
func TestInstallerLoginUsesEmbyWithoutCreatingTVSession(t *testing.T) {
var logoutCalls atomic.Int32
var deleteCalls atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/Users/AuthenticateByName":
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{
"User":{"Id":"emby-user","Name":"Matt"},
"AccessToken":"temporary-emby-token",
"ServerId":"emby-server"
}`)
case "/Sessions/Logout":
logoutCalls.Add(1)
w.WriteHeader(http.StatusNoContent)
case "/Devices":
if r.Method == http.MethodGet {
_, _ = io.WriteString(w, `{"Items":[
{"Id":"keep","ReportedDeviceId":"living-room-tv"},
{"Id":"remove","ReportedDeviceId":"memby-web-installer"}
]}`)
} else if r.Method == http.MethodDelete && r.URL.Query().Get("Id") == "remove" {
deleteCalls.Add(1)
w.WriteHeader(http.StatusNoContent)
} else {
http.Error(w, "unexpected device request", http.StatusBadRequest)
}
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
s := &Server{
cfg: config.Config{
ReleasePublishToken: "test-release-secret",
SyncUserID: "service-user", SyncAPIKey: "service-token",
},
emby: emby.New(upstream.URL, upstream.URL, "Memby test", 2*time.Second),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
form := url.Values{
"username": {"Matt"}, "password": {"correct horse"}, "next": {"/admin/"},
}
req := httptest.NewRequest(http.MethodPost, "/install/login",
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
s.handleInstallLogin(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("login response = %d, location %q", rec.Code, rec.Header().Get("Location"))
}
if logoutCalls.Load() != 1 {
t.Fatalf("Emby logout calls = %d, want 1", logoutCalls.Load())
}
if deleteCalls.Load() != 1 {
t.Fatalf("Emby device delete calls = %d, want 1", deleteCalls.Load())
}
cookies := rec.Result().Cookies()
if len(cookies) != 1 || cookies[0].Name != installerCookieName ||
!cookies[0].HttpOnly || !cookies[0].Secure ||
cookies[0].SameSite != http.SameSiteStrictMode {
t.Fatalf("installer cookie is not hardened: %+v", cookies)
}
follow := httptest.NewRequest(http.MethodGet, "/install", nil)
follow.AddCookie(cookies[0])
if !s.validInstallerSession(follow) {
t.Fatal("issued installer session was not accepted")
}
}
func addInstallerSession(t *testing.T, s *Server, req *http.Request) {
t.Helper()
value, err := s.newInstallerSession()
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: installerCookieName, Value: value})
}
func TestFileSHA256(t *testing.T) {
path := filepath.Join(t.TempDir(), "release.apk")
payload := []byte("a complete release payload")
if err := os.WriteFile(path, payload, 0o600); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(payload)
want := hex.EncodeToString(sum[:])
got, err := fileSHA256(path)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("fileSHA256() = %q, want %q", got, want)
}
}
func TestParseMandatoryRelease(t *testing.T) {
for _, test := range []struct {
value string
mandatory bool
valid bool
}{
{"", false, true},
{"0", false, true},
{"false", false, true},
{"1", true, true},
{"TRUE", true, true},
{"sometimes", false, false},
} {
mandatory, valid := parseMandatoryRelease(test.value)
if mandatory != test.mandatory || valid != test.valid {
t.Errorf("parseMandatoryRelease(%q) = (%v, %v), want (%v, %v)",
test.value, mandatory, valid, test.mandatory, test.valid)
}
}
}
+238
View File
@@ -0,0 +1,238 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
type requestCandidate struct {
MediaType string `json:"mediaType"`
ForeignID int `json:"foreignId"`
Title string `json:"title"`
Year int `json:"year"`
Overview string `json:"overview"`
PosterURL string `json:"posterUrl,omitempty"`
AlreadyAdded bool `json:"alreadyAdded"`
}
type requestLookupResponse struct {
Candidates []requestCandidate `json:"candidates"`
}
func (s *Server) requestAllowed(r *http.Request, sess store.Session) bool {
if s.store == nil || (s.sonarr == nil && s.radarr == nil) {
return false
}
policy, err := s.store.RequestPolicy(r.Context())
if err != nil {
s.log.Error("request policy read failed", "error", err)
return false
}
return policy.Allows(sess.EmbyUserID)
}
func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, sess store.Session) {
if !s.requestAllowed(r, sess) {
writeError(w, http.StatusForbidden, "media requests are not enabled for this user")
return
}
term := strings.TrimSpace(r.URL.Query().Get("q"))
if len(term) < 2 || len(term) > 160 {
writeError(w, http.StatusBadRequest, "query must be between 2 and 160 characters")
return
}
var movieCandidates []requestCandidate
var seriesCandidates []requestCandidate
var wg sync.WaitGroup
if s.radarr != nil {
wg.Add(1)
go func() {
defer wg.Done()
movies, err := s.radarr.Lookup(r.Context(), term)
if err != nil {
s.log.Warn("Radarr request lookup failed", "error", err)
return
}
for _, movie := range movies {
if movie.TMDBID == 0 || len(movieCandidates) >= 5 {
continue
}
movieCandidates = append(movieCandidates, requestCandidate{
MediaType: "movie", ForeignID: movie.TMDBID, Title: movie.Title,
Year: movie.Year, Overview: movie.Overview,
PosterURL: radarrCoverURL(movie.Images, "poster"), AlreadyAdded: movie.ID > 0,
})
}
}()
}
if s.sonarr != nil {
wg.Add(1)
go func() {
defer wg.Done()
series, err := s.sonarr.Lookup(r.Context(), term)
if err != nil {
s.log.Warn("Sonarr request lookup failed", "error", err)
return
}
for _, show := range series {
if show.TVDBID == 0 || len(seriesCandidates) >= 5 {
continue
}
seriesCandidates = append(seriesCandidates, requestCandidate{
MediaType: "series", ForeignID: show.TVDBID, Title: show.Title,
Year: show.Year, Overview: show.Overview,
PosterURL: sonarrCoverURL(show.Images, "poster"), AlreadyAdded: show.ID > 0,
})
}
}()
}
wg.Wait()
candidates := append(movieCandidates, seriesCandidates...)
sort.SliceStable(candidates, func(i, j int) bool {
return requestMatchScore(term, candidates[i].Title) < requestMatchScore(term, candidates[j].Title)
})
writeJSON(w, http.StatusOK, requestLookupResponse{Candidates: candidates})
}
func requestMatchScore(term, title string) int {
term = strings.ToLower(strings.TrimSpace(term))
title = strings.ToLower(strings.TrimSpace(title))
switch {
case title == term:
return 0
case strings.HasPrefix(title, term):
return 1
case strings.Contains(title, term):
return 2
default:
return 3
}
}
func radarrCoverURL(images []radarr.Image, kind string) string {
for _, image := range images {
if image.CoverType == kind {
if image.RemoteURL != "" {
return image.RemoteURL
}
return image.URL
}
}
return ""
}
func sonarrCoverURL(images []sonarr.Image, kind string) string {
for _, image := range images {
if image.CoverType == kind {
if image.RemoteURL != "" {
return image.RemoteURL
}
return image.URL
}
}
return ""
}
type requestPayload struct {
MediaType string `json:"mediaType"`
ForeignID int `json:"foreignId"`
}
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess store.Session) {
if !s.requestAllowed(r, sess) {
writeError(w, http.StatusForbidden, "media requests are not enabled for this user")
return
}
var req requestPayload
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if req.ForeignID <= 0 {
writeError(w, http.StatusBadRequest, "foreignId is required")
return
}
switch req.MediaType {
case "movie":
if s.radarr == nil {
writeError(w, http.StatusServiceUnavailable, "Radarr is not configured")
return
}
movies, err := s.radarr.Lookup(r.Context(), "tmdb:"+strconv.Itoa(req.ForeignID))
if err != nil {
s.writeRequestUpstreamError(w, err, "Radarr lookup failed")
return
}
for _, movie := range movies {
if movie.TMDBID != req.ForeignID {
continue
}
if movie.ID > 0 {
writeError(w, http.StatusConflict, "this movie is already in Radarr")
return
}
added, err := s.radarr.AddUnmonitored(r.Context(), movie)
if err != nil {
s.writeRequestUpstreamError(w, err, "could not add movie to Radarr")
return
}
s.log.Info("media requested", "user", sess.Username, "type", "movie", "title", added.Title)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
}
case "series":
if s.sonarr == nil {
writeError(w, http.StatusServiceUnavailable, "Sonarr is not configured")
return
}
series, err := s.sonarr.Lookup(r.Context(), "tvdb:"+strconv.Itoa(req.ForeignID))
if err != nil {
s.writeRequestUpstreamError(w, err, "Sonarr lookup failed")
return
}
for _, show := range series {
if show.TVDBID != req.ForeignID {
continue
}
if show.ID > 0 {
writeError(w, http.StatusConflict, "this series is already in Sonarr")
return
}
added, err := s.sonarr.AddUnmonitored(r.Context(), show)
if err != nil {
s.writeRequestUpstreamError(w, err, "could not add series to Sonarr")
return
}
s.log.Info("media requested", "user", sess.Username, "type", "series", "title", added.Title)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
}
default:
writeError(w, http.StatusBadRequest, `mediaType must be "movie" or "series"`)
return
}
writeError(w, http.StatusNotFound, "title was not found")
}
func (s *Server) writeRequestUpstreamError(w http.ResponseWriter, err error, message string) {
var radarrErr *radarr.APIError
var sonarrErr *sonarr.APIError
if (errors.As(err, &radarrErr) && radarrErr.StatusCode == http.StatusBadRequest) ||
(errors.As(err, &sonarrErr) && sonarrErr.StatusCode == http.StatusBadRequest) {
writeError(w, http.StatusConflict, "the title could not be added; it may already exist")
return
}
s.log.Error(message, "error", err)
writeError(w, http.StatusBadGateway, message)
}
+13
View File
@@ -0,0 +1,13 @@
package api
import "testing"
func TestRequestMatchScorePrefersTheSpecificTitle(t *testing.T) {
if requestMatchScore("the office", "The Office") >=
requestMatchScore("the office", "The Office Christmas Special") {
t.Fatal("exact title should rank ahead of a prefix match")
}
if requestMatchScore("arrival", "Arrival") >= requestMatchScore("arrival", "The Arrival") {
t.Fatal("exact title should rank ahead of a contained match")
}
}
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"context"
"fmt"
"time"
"github.com/ponzischeme89/memby/server/internal/library"
)
// News about the service itself, as opposed to news about what is in it.
//
// Both producers here answer the same question a viewer would otherwise have to guess
// at: why the library changed under them, and why playback stopped working. They are
// the alerts most worth showing *during* a film, which is why they are worded to be read
// in one glance and never ask for an action.
const (
// A refresh is only interesting while it is recent — long enough that a set switched
// on shortly afterwards still learns why there is something new on the home screen,
// short enough that it is never yesterday's news.
librarySyncAlertWindow = 30 * time.Minute
// Reachability is a live fact, so its window is short: a TV waking up half an hour
// after Emby went down should find out by asking, not by being told about the past.
reachabilityAlertWindow = 10 * time.Minute
// embyFailureThreshold is how many probes in a row must fail before it is announced.
// One timeout is a hiccup — a restart, a slow scan — and telling a room full of
// people about it is worse than saying nothing.
embyFailureThreshold = 3
)
// AnnounceLibrarySync tells every TV that the catalogue moved.
//
// Only a run that actually changed something is announced: the import is scheduled, so
// most passes find nothing, and an hourly "nothing happened" banner would train viewers
// to ignore the one that matters. Removals alone are deliberately silent — a title
// disappearing is not something to celebrate mid-film.
func (s *Server) AnnounceLibrarySync(ctx context.Context, result library.Result) {
if result.Changed <= 0 {
return
}
now := time.Now().UTC()
s.publishAlert(ctx, clientAlert{
// Keyed on the minute the sync finished: two runs are two pieces of news, but a
// retried publish of the same run is not.
ID: fmt.Sprintf("library:%d", now.Truncate(time.Minute).Unix()),
Kind: alertKindLibrarySync,
Label: "LIBRARY UPDATED",
Title: librarySyncTitle(result.Changed),
Message: "Memby has finished refreshing — it is on the home screen now.",
AiredAt: now.Format(time.RFC3339),
}, librarySyncAlertWindow)
}
func librarySyncTitle(changed int) string {
if changed == 1 {
return "1 title added or updated"
}
return fmt.Sprintf("%d titles added or updated", changed)
}
// WatchEmbyReachability announces Emby going away and coming back.
//
// This is the one alert that matters more during playback than on the home screen: video
// direct-plays from Emby, so when Emby stops answering a film stops with no explanation
// the viewer can act on. The gateway keeps serving /v1/status either way, which is what
// makes it able to say so.
//
// Only *transitions* are announced. A server that is down stays down, and repeating it
// every minute would bury everything else.
func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Duration) {
if interval <= 0 {
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
// Start out assuming reachable: a gateway booting while Emby is down should not
// open with a banner about a state nobody has seen change.
reachable := true
failures := 0
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := s.emby.Ping(probeCtx)
cancel()
if err != nil {
failures++
if reachable && failures >= embyFailureThreshold {
reachable = false
s.log.Warn("emby unreachable, announcing", "error", err)
s.publishAlert(ctx, s.reachabilityAlert(false), reachabilityAlertWindow)
}
continue
}
if !reachable {
s.log.Info("emby reachable again, announcing")
s.publishAlert(ctx, s.reachabilityAlert(true), reachabilityAlertWindow)
}
reachable = true
failures = 0
}
}
}
// reachabilityAlert is timestamped per transition, so the "back online" banner never
// collides with the "not responding" one it replaces.
func (s *Server) reachabilityAlert(up bool) clientAlert {
now := time.Now().UTC()
if up {
return clientAlert{
ID: fmt.Sprintf("emby:up:%d", now.Unix()),
Kind: alertKindServerUp,
Label: "SERVER BACK ONLINE",
Title: "Emby is responding again",
Message: "Playback and browsing are working normally.",
AiredAt: now.Format(time.RFC3339),
}
}
return clientAlert{
ID: fmt.Sprintf("emby:down:%d", now.Unix()),
Kind: alertKindServerDown,
Label: "SERVER NOT RESPONDING",
Title: "Emby has stopped communicating",
// Says what the viewer will see rather than what failed: a diagnosis they cannot
// act on from the sofa is worse than none.
Message: "Playback may stop until it is back. Memby will say when it returns.",
AiredAt: now.Format(time.RFC3339),
}
}
+51
View File
@@ -0,0 +1,51 @@
package api
import (
"strings"
"testing"
"github.com/ponzischeme89/memby/server/internal/library"
)
func TestLibrarySyncTitleCountsWhatChanged(t *testing.T) {
if got := librarySyncTitle(1); got != "1 title added or updated" {
t.Errorf("one change reads %q", got)
}
if got := librarySyncTitle(24); !strings.HasPrefix(got, "24 titles") {
t.Errorf("many changes read %q", got)
}
}
func TestReachabilityAlertsAreDistinctAndSelfExplaining(t *testing.T) {
s := &Server{}
down := s.reachabilityAlert(false)
up := s.reachabilityAlert(true)
if down.Kind != alertKindServerDown || up.Kind != alertKindServerUp {
t.Fatalf("kinds = %q/%q, want the two reachability kinds", down.Kind, up.Kind)
}
// The recovery banner must never overwrite the outage banner it follows: they are
// two pieces of news, and a TV that saw neither should be able to show both.
if down.ID == up.ID {
t.Errorf("both transitions share id %q", down.ID)
}
for _, alert := range []clientAlert{down, up} {
if alert.Label == "" || alert.Title == "" || alert.Message == "" {
t.Errorf("alert %q has nothing to render: %+v", alert.Kind, alert)
}
// These are the alerts most likely to land mid-film, where there is no remote
// interaction to be had — they must state a fact, not ask for one.
if strings.Contains(alert.Message, "?") {
t.Errorf("alert %q asks the viewer something: %q", alert.Kind, alert.Message)
}
}
}
// A sync that changed nothing must be silent: the import is scheduled, most passes find
// nothing, and an hourly "no news" banner would train viewers to ignore the real ones.
func TestAnnounceLibrarySyncIgnoresAQuietPass(t *testing.T) {
// A nil cache would panic if this tried to publish, which is the assertion.
s := &Server{log: discardLogger()}
s.AnnounceLibrarySync(t.Context(), library.Result{Seen: 4200, Changed: 0, Removed: 0})
}
+79 -10
View File
@@ -14,8 +14,9 @@ import (
"github.com/ponzischeme89/memby/server/internal/store"
)
const sonarrCalendarCachePrefix = "sonarr:calendar:"
const sonarrPrerollCachePrefix = "sonarr:preroll:"
const sonarrCalendarCachePrefix = "sonarr:calendar:v2:"
const sonarrPrerollCachePrefix = "sonarr:preroll:v2:"
const sonarrScheduleDays = 5
type prerollScheduleResponse struct {
Today []prerollScheduleEntry `json:"today"`
@@ -23,6 +24,8 @@ type prerollScheduleResponse struct {
}
type prerollScheduleEntry struct {
ItemID string `json:"itemId"`
ImageType string `json:"imageType,omitempty"`
Series string `json:"series"`
Episode string `json:"episode"`
EpisodeCode string `json:"episodeCode"`
@@ -103,12 +106,22 @@ func buildPrerollSchedule(
continue
}
airTime := episode.AirDateUTC.In(location)
seriesID := episode.Series.ID
if seriesID == 0 {
seriesID = episode.SeriesID
}
entry := prerollScheduleEntry{
ItemID: fmt.Sprintf("sonarr:%d:%d", seriesID, episode.ID),
Series: episode.Series.Title,
Episode: episode.Title,
EpisodeCode: fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber),
Availability: prerollAvailability(episode),
}
if hasCover(episode.Series.Images, "fanart") {
entry.ImageType = "backdrop"
} else if hasCover(episode.Series.Images, "poster") {
entry.ImageType = "primary"
}
if airTime.Before(todayEnd) {
entry.Schedule = airTime.Format("3:04 PM")
if len(result.Today) < 4 {
@@ -169,6 +182,7 @@ type sonarrScheduleItem struct {
MembyEpisodeCode string `json:"MembyEpisodeCode"`
MembyAirsAt string `json:"MembyAirsAt,omitempty"`
MembyAddedAt string `json:"MembyAddedAt,omitempty"`
MembyAirDayLabel string `json:"MembyAirDayLabel"`
MembyAirLabel string `json:"MembyAirLabel"`
MembyAvailability string `json:"MembyAvailability"`
MembyAvailabilityText string `json:"MembyAvailabilityText"`
@@ -199,7 +213,11 @@ func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, erro
return row, nil
}
episodes, err := s.sonarr.Calendar(ctx, dayStart, dayStart.AddDate(0, 0, 1))
episodes, err := s.sonarr.Calendar(
ctx,
dayStart,
dayStart.AddDate(0, 0, sonarrScheduleDays),
)
if err != nil {
return nil, err
}
@@ -240,7 +258,16 @@ func buildSonarrRow(episodes []sonarr.Episode, now time.Time, location *time.Loc
})
items := make([]json.RawMessage, 0, len(episodes))
dayStart := localDayStart(now, location)
windowEnd := dayStart.AddDate(0, 0, sonarrScheduleDays)
for _, episode := range episodes {
if episode.AirDateUTC == nil {
continue
}
localAirTime := episode.AirDateUTC.In(location)
if localAirTime.Before(dayStart) || !localAirTime.Before(windowEnd) {
continue
}
item := toSonarrScheduleItem(episode, now, location)
raw, err := json.Marshal(item)
if err != nil {
@@ -250,7 +277,7 @@ func buildSonarrRow(episodes []sonarr.Episode, now time.Time, location *time.Loc
}
return &recommend.Row{
ID: "sonarr-airing-today",
Title: "Shows airing today",
Title: "Shows airing in the next 5 days",
Kind: "schedule",
Items: items,
}, nil
@@ -286,13 +313,11 @@ func toSonarrScheduleItem(episode sonarr.Episode, now time.Time, location *time.
if episode.AirDateUTC != nil {
airTime := episode.AirDateUTC.In(location)
item.MembyAirsAt = airTime.Format(time.RFC3339)
if airTime.After(now) {
item.MembyAirLabel = "Airs today at " + airTime.Format("3:04 PM")
} else {
item.MembyAirLabel = "Aired today at " + airTime.Format("3:04 PM")
}
item.MembyAirDayLabel = scheduleAirDayLabel(airTime, now, location)
item.MembyAirLabel = scheduleAirLabel(airTime, now, location)
} else {
item.MembyAirLabel = "Airs today"
item.MembyAirDayLabel = "Upcoming"
item.MembyAirLabel = "Coming up"
}
addedAt := episodeFileAddedAt(episode)
@@ -321,6 +346,50 @@ func toSonarrScheduleItem(episode sonarr.Episode, now time.Time, location *time.
return item
}
func scheduleAirDayLabel(airTime, now time.Time, location *time.Location) string {
airTime = airTime.In(location)
now = now.In(location)
today := localDayStart(now, location)
airDay := localDayStart(airTime, location)
switch {
case airDay.Equal(today):
return "Today"
case airDay.Equal(today.AddDate(0, 0, 1)):
return "Tomorrow"
default:
return airTime.Format("Monday")
}
}
func scheduleAirLabel(airTime, now time.Time, location *time.Location) string {
airTime = airTime.In(location)
now = now.In(location)
clock := airTime.Format("3:04 PM")
today := localDayStart(now, location)
airDay := localDayStart(airTime, location)
switch {
case airDay.Equal(today) && !airTime.After(now):
return "Aired today at " + clock
case airDay.Equal(today):
remaining := airTime.Sub(now)
if remaining < time.Hour {
minutes := int((remaining + time.Minute - 1) / time.Minute)
return fmt.Sprintf("In %d minutes (%s)", minutes, clock)
}
hours := int((remaining + time.Hour - 1) / time.Hour)
return fmt.Sprintf("In %d hours (%s)", hours, clock)
case airDay.Equal(today.AddDate(0, 0, 1)):
return "Tomorrow: " + clock
default:
return airTime.Format("Monday") + ": " + clock
}
}
func localDayStart(value time.Time, location *time.Location) time.Time {
value = value.In(location)
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, location)
}
func episodeFileAddedAt(episode sonarr.Episode) *time.Time {
if episode.EpisodeFile == nil {
return nil
+57 -3
View File
@@ -59,8 +59,10 @@ func TestBuildPrerollScheduleSplitsTodayAndWeek(t *testing.T) {
thursday := time.Date(2026, 7, 30, 19, 0, 0, 0, location).UTC()
schedule := buildPrerollSchedule([]sonarr.Episode{
{
SeasonNumber: 1, EpisodeNumber: 4, Title: "Tonight",
AirDateUTC: &today, Series: sonarr.Series{Title: "Northbound"},
ID: 14, SeriesID: 7, SeasonNumber: 1, EpisodeNumber: 4, Title: "Tonight",
AirDateUTC: &today, Series: sonarr.Series{
ID: 7, Title: "Northbound", Images: []sonarr.Image{{CoverType: "fanart"}},
},
},
{
SeasonNumber: 2, EpisodeNumber: 1, Title: "Later",
@@ -69,7 +71,8 @@ func TestBuildPrerollScheduleSplitsTodayAndWeek(t *testing.T) {
}, now, location)
if len(schedule.Today) != 1 || schedule.Today[0].Series != "Northbound" ||
schedule.Today[0].Schedule != "8:30 PM" {
schedule.Today[0].Schedule != "8:30 PM" || schedule.Today[0].ItemID != "sonarr:7:14" ||
schedule.Today[0].ImageType != "backdrop" {
t.Fatalf("unexpected today schedule: %+v", schedule.Today)
}
if len(schedule.ThisWeek) != 1 || schedule.ThisWeek[0].EpisodeCode != "S02E01" ||
@@ -77,3 +80,54 @@ func TestBuildPrerollScheduleSplitsTodayAndWeek(t *testing.T) {
t.Fatalf("unexpected week schedule: %+v", schedule.ThisWeek)
}
}
func TestBuildSonarrRowCoversFiveDaysAndUsesRelativeAirLabels(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 29, 8, 0, 0, 0, location)
episode := func(id int, air time.Time) sonarr.Episode {
utc := air.UTC()
return sonarr.Episode{
ID: id, SeriesID: id, SeasonNumber: 1, EpisodeNumber: id,
Title: "Episode", AirDateUTC: &utc,
Series: sonarr.Series{ID: id, Title: "Show"},
}
}
row, err := buildSonarrRow([]sonarr.Episode{
episode(1, time.Date(2026, 7, 29, 16, 0, 0, 0, location)),
episode(2, time.Date(2026, 7, 30, 16, 0, 0, 0, location)),
episode(3, time.Date(2026, 8, 2, 16, 0, 0, 0, location)),
episode(4, time.Date(2026, 8, 3, 16, 0, 0, 0, location)),
}, now, location)
if err != nil {
t.Fatal(err)
}
if row.Title != "Shows airing in the next 5 days" || len(row.Items) != 3 {
t.Fatalf("five-day row = %+v", row)
}
labels := make([]string, 0, len(row.Items))
dayLabels := make([]string, 0, len(row.Items))
for _, raw := range row.Items {
var item sonarrScheduleItem
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatal(err)
}
labels = append(labels, item.MembyAirLabel)
dayLabels = append(dayLabels, item.MembyAirDayLabel)
}
want := []string{
"In 8 hours (4:00 PM)",
"Tomorrow: 4:00 PM",
"Sunday: 4:00 PM",
}
for i := range want {
if labels[i] != want[i] {
t.Fatalf("labels = %#v, want %#v", labels, want)
}
}
wantDays := []string{"Today", "Tomorrow", "Sunday"}
for i := range wantDays {
if dayLabels[i] != wantDays[i] {
t.Fatalf("day labels = %#v, want %#v", dayLabels, wantDays)
}
}
}
+9 -5
View File
@@ -9,7 +9,6 @@ import (
"time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/store"
)
// membyProtocolVersion changes only when the client/server wire contract is no longer
@@ -73,6 +72,11 @@ func clientProtocol(r *http.Request) string {
return strings.TrimSpace(r.Header.Get("X-Memby-Protocol"))
}
func clientProtocolNumber(r *http.Request) int {
reported, _ := strconv.Atoi(clientProtocol(r))
return reported
}
func compatibilityFor(r *http.Request) (bool, string) {
reported, err := strconv.Atoi(clientProtocol(r))
if err != nil || reported != membyProtocolVersion {
@@ -87,10 +91,10 @@ func compatibilityFor(r *http.Request) (bool, string) {
// handleUpdate answers the client's version check.
//
// Its own endpoint rather than a field on /v1/home: the home payload is cached per user,
// while this answer depends on the requesting client's version, so the two cannot share a
// cache entry. It costs nothing — the policy is held in memory.
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request, _ store.Session) {
// Its own public endpoint rather than a field on /v1/home: update policy belongs to the
// app build, not a viewer or login. The only client input is its build-version header and
// the answer comes from memory, so checking it never reads or mutates a user session.
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
decision := appupdate.Decide(s.updatePolicy.get(), clientVersion(r))
writeJSON(w, http.StatusOK, decision)
}
+40
View File
@@ -0,0 +1,40 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestUpdateStatusIsPublicAndAvailableDuringMaintenance(t *testing.T) {
server := testServer(config.Config{})
server.updatePolicy.set(appupdate.Policy{
Enabled: true,
LatestVersion: "0.3.0",
MinimumVersion: "0.2.9",
DownloadURL: "/updates/memby-0.3.0.apk?token=signed",
})
server.maintenance.set(store.Maintenance{Enabled: true, Message: "Deploying"})
req := httptest.NewRequest(http.MethodGet, "/v1/update", nil)
req.Header.Set("X-Memby-Version", "0.2.8")
rec := httptest.NewRecorder()
// Deliberately no Authorization header: update policy must precede login.
server.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", rec.Code, rec.Body.String())
}
var decision appupdate.Decision
if err := json.Unmarshal(rec.Body.Bytes(), &decision); err != nil {
t.Fatalf("decode decision: %v", err)
}
if decision.Status != appupdate.StatusMandatory {
t.Fatalf("status = %q, want mandatory", decision.Status)
}
}
+12 -3
View File
@@ -33,9 +33,14 @@ type Policy struct {
// current release mandatory for everyone.
MinimumVersion string `json:"minimumVersion"`
// DownloadURL points at the APK — normally the same file the landing page serves.
DownloadURL string `json:"downloadUrl"`
Notes string `json:"notes"`
UpdatedAt time.Time `json:"updatedAt"`
DownloadURL string `json:"downloadUrl"`
// SHA256 and SizeBytes let the TV reject a truncated, stale or substituted download
// before handing it to Android's package installer. Blank/zero remain valid for
// older policies entered manually through the admin page.
SHA256 string `json:"sha256,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
Notes string `json:"notes"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Decision is what the client receives.
@@ -44,6 +49,8 @@ type Decision struct {
Version string `json:"version"`
Notes string `json:"notes"`
DownloadURL string `json:"downloadUrl"`
SHA256 string `json:"sha256,omitempty"`
SizeBytes int64 `json:"sizeBytes,omitempty"`
}
// Decide compares the version a client reported against the policy.
@@ -67,6 +74,8 @@ func Decide(policy Policy, clientVersion string) Decision {
Version: normalize(policy.LatestVersion),
Notes: strings.TrimSpace(policy.Notes),
DownloadURL: strings.TrimSpace(policy.DownloadURL),
SHA256: strings.ToLower(strings.TrimSpace(policy.SHA256)),
SizeBytes: policy.SizeBytes,
}
if minimum := parseVersion(policy.MinimumVersion); len(minimum) > 0 && compare(client, minimum) < 0 {
+5 -1
View File
@@ -8,6 +8,8 @@ func policy() Policy {
LatestVersion: "0.1.54",
MinimumVersion: "0.1.50",
DownloadURL: "https://nas.example.com/memby/memby-0.1.54.apk",
SHA256: "abcdef",
SizeBytes: 42,
Notes: "Faster home screen",
}
}
@@ -29,6 +31,9 @@ func TestBehindLatestIsOptional(t *testing.T) {
if decision.Version != "0.1.54" || decision.DownloadURL == "" {
t.Fatalf("decision is missing what the TV needs to act: %+v", decision)
}
if decision.SHA256 != "abcdef" || decision.SizeBytes != 42 {
t.Fatalf("decision dropped release integrity metadata: %+v", decision)
}
}
func TestBelowMinimumIsMandatory(t *testing.T) {
@@ -100,7 +105,6 @@ func TestVersionParsingIsForgiving(t *testing.T) {
if got := Decide(p, "0.1.54-beta").Status; got != StatusNone {
t.Fatalf("pre-release suffix should compare as 0.1.54, got %s", got)
}
p.LatestVersion = "0.2"
if got := Decide(p, "0.2.0").Status; got != StatusNone {
t.Fatalf("0.2.0 should equal 0.2, got %s", got)
+2 -7
View File
@@ -84,14 +84,9 @@ func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID,
// RecommendationsKey sits in its own `r:` namespace on purpose.
//
// Recommendations cost several Emby queries to build, so they must survive the cache
// wipe that every favourite toggle triggers. Only a genuine change in viewing history
// — a finished playback — retires them, via [Cache.InvalidateRecommendations].
// Recommendations cost several Emby queries to build, so they survive ordinary user-view
// invalidation and expire on their own slow-moving daily cadence.
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v3", userID) }
func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error {
return c.Delete(ctx, RecommendationsKey(userID))
}
// SessionKey caches a token→session lookup, keyed by token hash (never the token).
func SessionKey(tokenHashHex string) string { return "sess:" + tokenHashHex }
+50 -14
View File
@@ -2,6 +2,7 @@
package config
import (
"encoding/json"
"fmt"
"os"
"strconv"
@@ -34,18 +35,21 @@ type Config struct {
SessionTTL time.Duration
// SessionIdleExpiry retires gateway tokens that go unused for this long.
SessionIdleExpiry time.Duration
// MaxClientsPerUser is enforced transactionally when a new TV signs in.
MaxClientsPerUser int
// RecommendTTL is how long computed recommendation rows stay warm. Long, because
// taste moves slowly and each rebuild costs several Emby queries.
RecommendTTL time.Duration
// RecommendTimeout bounds a background rebuild, which fans out further than a
// normal request and so needs more headroom than UpstreamTimeout.
RecommendTimeout time.Duration
// RecommendationWeights is an optional JSON overlay on the weighted defaults.
RecommendationWeights string
UpstreamTimeout time.Duration
// EmbyHealthInterval paces the reachability probe behind the "server not responding"
// and "back online" banners. One probe per gateway, not per TV. Zero disables it.
EmbyHealthInterval time.Duration
// AdminToken guards the operator interface. Empty disables /admin entirely, so an
// unconfigured deployment cannot leave it exposed.
AdminToken string
@@ -73,7 +77,7 @@ type Config struct {
AnalyticsRetention time.Duration
// Sonarr is optional. When configured, its local calendar supplies the informational
// "Shows airing today" home row. The API key never leaves this server.
// Five-day "Shows airing" home row. The API key never leaves this server.
SonarrURL string
SonarrAPIKey string
SonarrTTL time.Duration
@@ -81,9 +85,24 @@ type Config struct {
// SonarrAlertWindow is how long after an episode airs the "aired, coming soon"
// banner keeps being offered to clients. Zero turns the banners off without
// touching the airing-today row.
// touching the five-day schedule row.
SonarrAlertWindow time.Duration
// Radarr is optional. Its calendar supplies the five-day digital movie release row.
RadarrURL string
RadarrAPIKey string
RadarrTTL time.Duration
RadarrLocation *time.Location
// RadarrWebhookToken guards the Radarr "on import" webhook. Empty means the hook
// 404s, so an unconfigured deployment cannot be fed alerts by anyone who finds it.
RadarrWebhookToken string
// RadarrAlertWindow is how long an imported movie keeps being announced to clients.
// It is a window rather than a one-shot push because the gateway has no connection
// to a TV that is switched off: a set turned on inside the window still gets the
// news, and each TV dedupes by alert id. Zero turns the banners off.
RadarrAlertWindow time.Duration
// Tracearr is an optional, read-only source of completion, session-length and
// direct-play signals for the per-user For You area. The public API key stays in
// the gateway and is never returned to a TV.
@@ -94,10 +113,11 @@ type Config struct {
// late/out-of-order updates and deletions without needing a source cursor.
TracearrSyncInterval time.Duration
TracearrFullInterval time.Duration
// ForYouMinRebuildAge coalesces bursts of playback/library changes. RefreshInterval
// is the acceptable age of a prepared pool before it is refreshed.
// Prepared pools rebuild daily at RebuildHour in the configured timezone. The age
// settings are safety bounds for manual/background fallback paths.
ForYouMinRebuildAge time.Duration
ForYouRefreshInterval time.Duration
ForYouRebuildHour int
}
func Load() (Config, error) {
@@ -114,9 +134,11 @@ func Load() (Config, error) {
ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute),
SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute),
SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour),
MaxClientsPerUser: integer("MEMBY_MAX_CLIENTS_PER_USER", 1),
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 2*time.Hour),
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 24*time.Hour),
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
RecommendationWeights: strings.TrimSpace(
os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"),
),
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
@@ -131,17 +153,24 @@ 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),
EmbyHealthInterval: duration("MEMBY_EMBY_HEALTH_INTERVAL", 60*time.Second),
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
SonarrAlertWindow: duration("MEMBY_SONARR_ALERT_WINDOW", 3*time.Hour),
RadarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RADARR_URL")), "/"),
RadarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_RADARR_API_KEY")),
RadarrTTL: duration("MEMBY_RADARR_TTL", 5*time.Minute),
RadarrWebhookToken: strings.TrimSpace(os.Getenv("MEMBY_RADARR_WEBHOOK_TOKEN")),
RadarrAlertWindow: duration("MEMBY_RADARR_ALERT_WINDOW", 3*time.Hour),
TracearrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_URL")), "/"),
TracearrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_API_KEY")),
TracearrServerID: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_SERVER_ID")),
TracearrSyncInterval: duration("MEMBY_TRACEARR_SYNC_INTERVAL", 5*time.Minute),
TracearrFullInterval: duration("MEMBY_TRACEARR_FULL_INTERVAL", 24*time.Hour),
ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 10*time.Minute),
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 30*time.Minute),
ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 24*time.Hour),
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
}
if c.EmbyURL == "" {
@@ -150,9 +179,6 @@ func Load() (Config, error) {
if c.DatabaseURL == "" {
return c, fmt.Errorf("MEMBY_DATABASE_URL is required")
}
if c.MaxClientsPerUser < 1 {
return c, fmt.Errorf("MEMBY_MAX_CLIENTS_PER_USER must be at least 1")
}
if c.EmbyPublicURL == "" {
c.EmbyPublicURL = c.EmbyURL
}
@@ -162,14 +188,24 @@ func Load() (Config, error) {
if (c.SonarrURL == "") != (c.SonarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together")
}
if (c.RadarrURL == "") != (c.RadarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_RADARR_URL and MEMBY_RADARR_API_KEY must be set together")
}
if (c.TracearrURL == "") != (c.TracearrAPIKey == "") {
return c, fmt.Errorf("MEMBY_TRACEARR_URL and MEMBY_TRACEARR_API_KEY must be set together")
}
if c.ForYouRebuildHour < 0 || c.ForYouRebuildHour > 23 {
return c, fmt.Errorf("MEMBY_FOR_YOU_REBUILD_HOUR must be between 0 and 23")
}
if c.RecommendationWeights != "" && !json.Valid([]byte(c.RecommendationWeights)) {
return c, fmt.Errorf("MEMBY_RECOMMENDATION_WEIGHTS must be valid JSON")
}
location, err := time.LoadLocation(env("MEMBY_TIMEZONE", "Pacific/Auckland"))
if err != nil {
return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err)
}
c.SonarrLocation = location
c.RadarrLocation = location
return c, nil
}
+52 -15
View File
@@ -1,20 +1,9 @@
package config
import "testing"
func TestDefaultClientAllowanceIsOne(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_MAX_CLIENTS_PER_USER", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.MaxClientsPerUser != 1 {
t.Fatalf("MaxClientsPerUser = %d, want 1", cfg.MaxClientsPerUser)
}
}
import (
"testing"
"time"
)
func TestTracearrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
@@ -26,3 +15,51 @@ func TestTracearrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
t.Fatal("expected incomplete Tracearr configuration to fail")
}
}
func TestRadarrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_RADARR_URL", "http://radarr")
t.Setenv("MEMBY_RADARR_API_KEY", "")
if _, err := Load(); err == nil {
t.Fatal("expected incomplete Radarr configuration to fail")
}
}
func TestForYouDefaultsToDailyOffPeakRebuild(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_FOR_YOU_MIN_REBUILD_AGE", "")
t.Setenv("MEMBY_FOR_YOU_REFRESH_INTERVAL", "")
t.Setenv("MEMBY_FOR_YOU_REBUILD_HOUR", "")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.ForYouMinRebuildAge != 24*time.Hour ||
cfg.ForYouRefreshInterval != 24*time.Hour ||
cfg.ForYouRebuildHour != 4 {
t.Fatalf("For You schedule = %v/%v hour %d",
cfg.ForYouMinRebuildAge, cfg.ForYouRefreshInterval, cfg.ForYouRebuildHour)
}
}
func TestForYouRebuildHourIsValidated(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_FOR_YOU_REBUILD_HOUR", "24")
if _, err := Load(); err == nil {
t.Fatal("expected invalid rebuild hour to fail")
}
}
func TestRecommendationWeightsMustBeJSON(t *testing.T) {
t.Setenv("MEMBY_EMBY_URL", "http://emby")
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
t.Setenv("MEMBY_RECOMMENDATION_WEIGHTS", "{broken")
if _, err := Load(); err == nil {
t.Fatal("expected invalid recommendation weights to fail")
}
}
+74 -41
View File
@@ -33,6 +33,15 @@ type Credentials struct {
DeviceName string
}
type Device struct {
ID string `json:"Id"`
ReportedDeviceID string `json:"ReportedDeviceId"`
}
type DevicesResult struct {
Items []Device `json:"Items"`
}
type ItemsResult struct {
Items []json.RawMessage `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
@@ -57,10 +66,15 @@ type User struct {
// Summary is the minimal view of an item the gateway needs for its own logic.
type Summary struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
UserData struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
Overview string `json:"Overview"`
SeriesName string `json:"SeriesName"`
RunTimeTicks int64 `json:"RunTimeTicks"`
ParentIndexNumber int `json:"ParentIndexNumber"`
IndexNumber int `json:"IndexNumber"`
UserData struct {
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
} `json:"UserData"`
}
@@ -71,10 +85,13 @@ type PlaybackInfo struct {
}
type MediaSourceInfo struct {
ID string `json:"Id"`
MediaStreams []MediaStream `json:"MediaStreams"`
DirectStreamURL string `json:"DirectStreamUrl"`
TranscodingURL string `json:"TranscodingUrl"`
ID string `json:"Id"`
MediaStreams []MediaStream `json:"MediaStreams"`
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
SupportsDirectStream bool `json:"SupportsDirectStream"`
SupportsTranscoding bool `json:"SupportsTranscoding"`
DirectStreamURL string `json:"DirectStreamUrl"`
TranscodingURL string `json:"TranscodingUrl"`
}
type MediaStream struct {
@@ -152,6 +169,38 @@ func (c *Client) Logout(ctx context.Context, cred Credentials) error {
return c.do(req, nil)
}
// DeleteDevice removes the persistent Emby device record whose client-supplied ID
// matches reportedDeviceID. Logging out only revokes the access token; Emby deliberately
// keeps the device in its dashboard until the record itself is deleted.
func (c *Client) DeleteDevice(
ctx context.Context,
cred Credentials,
reportedDeviceID string,
) error {
req, err := c.newRequest(ctx, http.MethodGet, "/Devices", nil, cred, nil)
if err != nil {
return err
}
var devices DevicesResult
if err := c.do(req, &devices); err != nil {
return err
}
for _, device := range devices.Items {
if device.ReportedDeviceID != reportedDeviceID || device.ID == "" {
continue
}
params := url.Values{"Id": {device.ID}}
req, err := c.newRequest(ctx, http.MethodDelete, "/Devices", params, cred, nil)
if err != nil {
return err
}
if err := c.do(req, nil); err != nil {
return err
}
}
return nil
}
// Users returns the household accounts visible to an administrative/service token.
// It is used only by the background For You builder, never on a television request.
func (c *Client) Users(ctx context.Context, cred Credentials) ([]User, error) {
@@ -225,6 +274,8 @@ func (c *Client) PlaybackInfo(
startTicks int64,
subtitleStreamIndex *int,
currentPlaySessionID string,
forceTranscode bool,
capabilities PlaybackCapabilities,
) (*PlaybackInfo, error) {
params := url.Values{
"UserId": {cred.UserID},
@@ -232,39 +283,10 @@ func (c *Client) PlaybackInfo(
}
body, err := json.Marshal(map[string]any{
"Id": itemID, "UserId": cred.UserID, "IsPlayback": true,
"StartTimeTicks": startTicks,
"DeviceProfile": map[string]any{
"Name": "Memby Android TV", "SupportedMediaTypes": "Video",
"DirectPlayProfiles": []map[string]string{
{
"Container": "mkv,mp4,m4v,mov,webm,ts,mpegts,avi",
"VideoCodec": "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4",
"AudioCodec": "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm",
"Type": "Video",
},
},
"TranscodingProfiles": []map[string]string{
{
"Container": "ts", "VideoCodec": "h264", "AudioCodec": "aac",
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
},
},
"SubtitleProfiles": []map[string]string{
{"Format": "srt", "Method": "External"},
{"Format": "subrip", "Method": "External"},
{"Format": "ass", "Method": "External"},
{"Format": "ssa", "Method": "External"},
{"Format": "vtt", "Method": "External"},
{"Format": "webvtt", "Method": "External"},
{"Format": "mov_text", "Method": "External"},
{"Format": "tx3g", "Method": "External"},
{"Format": "pgs", "Method": "Encode"},
{"Format": "pgssub", "Method": "Encode"},
{"Format": "sup", "Method": "Encode"},
{"Format": "vobsub", "Method": "Encode"},
{"Format": "dvdsub", "Method": "Encode"},
},
},
"StartTimeTicks": startTicks, "EnableDirectPlay": !forceTranscode,
"EnableDirectStream": !forceTranscode, "EnableTranscoding": true,
"AllowVideoStreamCopy": true, "AllowAudioStreamCopy": true,
"DeviceProfile": androidTVDeviceProfile(capabilities),
})
var requestBody map[string]any
if err == nil {
@@ -276,6 +298,10 @@ func (c *Client) PlaybackInfo(
if currentPlaySessionID != "" {
requestBody["CurrentPlaySessionId"] = currentPlaySessionID
}
if forceTranscode {
profile := requestBody["DeviceProfile"].(map[string]any)
profile["DirectPlayProfiles"] = []map[string]string{}
}
if err == nil {
body, err = json.Marshal(requestBody)
}
@@ -301,6 +327,13 @@ func (c *Client) PlaybackInfo(
return &out, nil
}
func directPlayVideoCodecs(supportsHEVC bool) string {
if supportsHEVC {
return "h264,hevc"
}
return "h264"
}
func (c *Client) ResumeItems(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items/Resume", params)
}
@@ -0,0 +1,34 @@
package emby
import "testing"
func TestDirectPlayVideoCodecsIncludeHEVCOnlyForCapableClient(t *testing.T) {
if got := directPlayVideoCodecs(false); got != "h264" {
t.Fatalf("baseline codecs = %q", got)
}
if got := directPlayVideoCodecs(true); got != "h264,hevc" {
t.Fatalf("HEVC codecs = %q", got)
}
}
func TestAndroidTVProfileConstrainsCodecLevelAndResolution(t *testing.T) {
profile := androidTVDeviceProfile(PlaybackCapabilities{
H264Profiles: []string{"baseline", "main", "high"},
H264Level: 52, H264MaxWidth: 3840, H264MaxHeight: 2160,
HEVC: true, HEVCMain: true, HEVCMain10: true,
HEVCMainLevel: 153, HEVCMain10Level: 153,
HEVCMaxWidth: 3840, HEVCMaxHeight: 2160,
})
direct := profile["DirectPlayProfiles"].([]map[string]string)
if direct[0]["VideoCodec"] != "h264,hevc" {
t.Fatalf("direct video codecs = %q", direct[0]["VideoCodec"])
}
transcode := profile["TranscodingProfiles"].([]map[string]string)
if transcode[0]["VideoCodec"] != "h264,hevc" {
t.Fatalf("stream-copy codecs = %q", transcode[0]["VideoCodec"])
}
codecProfiles := profile["CodecProfiles"].([]map[string]any)
if len(codecProfiles) < 6 {
t.Fatalf("codec profiles = %#v", codecProfiles)
}
}
+167
View File
@@ -0,0 +1,167 @@
// Playback profile generation is adapted from Wholphin's DeviceProfileUtils.kt,
// itself derived from Jellyfin Android TV.
//
// Wholphin: https://github.com/damontecres/Wholphin
// Jellyfin Android TV: https://github.com/jellyfin/jellyfin-androidtv
//
// Modifications Copyright (C) 2026 Memby contributors
// SPDX-License-Identifier: GPL-2.0-only
package emby
import "strconv"
// PlaybackCapabilities is the Android decoder evidence captured for one TV session.
// Zero values deliberately describe the legacy H.264-safe profile.
type PlaybackCapabilities struct {
H264Profiles []string
H264Level int
H264High10Level int
H264MaxWidth int
H264MaxHeight int
HEVC bool
HEVCMain bool
HEVCMain10 bool
HEVCMainLevel int
HEVCMain10Level int
HEVCMaxWidth int
HEVCMaxHeight int
HEVCHDR10 bool
HEVCHDR10Plus bool
HEVCDolbyVision bool
}
func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any {
videoCodecs := directPlayVideoCodecs(capabilities.HEVC)
return map[string]any{
"Name": "Memby Android TV", "SupportedMediaTypes": "Video",
"DirectPlayProfiles": []map[string]string{
{
"Container": "mkv,mp4,m4v,mov,ts,mpegts", "VideoCodec": videoCodecs,
"AudioCodec": "aac,mp3", "Type": "Video",
},
},
"TranscodingProfiles": []map[string]string{
{
// AllowVideoStreamCopy lets Emby keep a supported H.264/HEVC track and
// convert only incompatible audio or subtitles into an HLS stream.
"Container": "ts", "VideoCodec": videoCodecs, "AudioCodec": "aac",
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
},
},
"CodecProfiles": videoCodecProfiles(capabilities),
"SubtitleProfiles": androidTVSubtitleProfiles(),
}
}
func androidTVSubtitleProfiles() []map[string]string {
return []map[string]string{
{"Format": "srt", "Method": "External"},
{"Format": "subrip", "Method": "External"},
{"Format": "ass", "Method": "External"},
{"Format": "ssa", "Method": "External"},
{"Format": "vtt", "Method": "External"},
{"Format": "webvtt", "Method": "External"},
{"Format": "mov_text", "Method": "External"},
{"Format": "tx3g", "Method": "External"},
{"Format": "pgs", "Method": "Encode"},
{"Format": "pgssub", "Method": "Encode"},
{"Format": "sup", "Method": "Encode"},
{"Format": "vobsub", "Method": "Encode"},
{"Format": "dvdsub", "Method": "Encode"},
}
}
func videoCodecProfiles(capabilities PlaybackCapabilities) []map[string]any {
profiles := []map[string]any{}
if len(capabilities.H264Profiles) > 0 {
profiles = append(profiles, codecProfile("h264",
[]map[string]any{profileCondition("EqualsAny", "VideoProfile", joinProfiles(capabilities.H264Profiles))}, nil))
}
profiles = appendLevelProfile(
profiles, "h264", capabilities.H264Level, "baseline|constrained baseline|main|high",
)
profiles = appendLevelProfile(profiles, "h264", capabilities.H264High10Level, "high 10")
profiles = appendResolutionProfile(
profiles, "h264", capabilities.H264MaxWidth, capabilities.H264MaxHeight,
)
if !capabilities.HEVC {
return profiles
}
hevcProfiles := []string{}
if capabilities.HEVCMain {
hevcProfiles = append(hevcProfiles, "main")
}
if capabilities.HEVCMain10 {
hevcProfiles = append(hevcProfiles, "main 10")
}
if len(hevcProfiles) > 0 {
profiles = append(profiles, codecProfile("hevc",
[]map[string]any{profileCondition("EqualsAny", "VideoProfile", joinProfiles(hevcProfiles))}, nil))
}
profiles = appendLevelProfile(profiles, "hevc", capabilities.HEVCMainLevel, "main")
profiles = appendLevelProfile(profiles, "hevc", capabilities.HEVCMain10Level, "main 10")
profiles = appendResolutionProfile(
profiles, "hevc", capabilities.HEVCMaxWidth, capabilities.HEVCMaxHeight,
)
return profiles
}
func appendLevelProfile(profiles []map[string]any, codec string, level int, applyTo string) []map[string]any {
if level <= 0 {
return profiles
}
condition := "Equals"
if containsPipe(applyTo) {
condition = "EqualsAny"
}
return append(profiles, codecProfile(codec,
[]map[string]any{profileCondition("LessThanEqual", "VideoLevel", strconv.Itoa(level))},
[]map[string]any{profileCondition(condition, "VideoProfile", applyTo)}))
}
func appendResolutionProfile(profiles []map[string]any, codec string, width, height int) []map[string]any {
if width <= 0 || height <= 0 {
return profiles
}
return append(profiles, codecProfile(codec, []map[string]any{
profileCondition("LessThanEqual", "Width", strconv.Itoa(width)),
profileCondition("LessThanEqual", "Height", strconv.Itoa(height)),
}, nil))
}
func codecProfile(codec string, conditions, applyConditions []map[string]any) map[string]any {
profile := map[string]any{
"Type": "Video", "Codec": codec, "Conditions": conditions,
}
if len(applyConditions) > 0 {
profile["ApplyConditions"] = applyConditions
}
return profile
}
func profileCondition(condition, property, value string) map[string]any {
return map[string]any{
"Condition": condition, "Property": property, "Value": value, "IsRequired": false,
}
}
func joinProfiles(values []string) string {
result := ""
for _, value := range values {
if result != "" {
result += "|"
}
result += value
}
return result
}
func containsPipe(value string) bool {
for _, char := range value {
if char == '|' {
return true
}
}
return false
}
+219 -44
View File
@@ -30,6 +30,10 @@ const (
unchangedPagesToStop = 2
preparedPoolReadLimit = 240
preparedRowSize = 20
maxPreparedCandidates = 750
// Increment only when stored eligibility, scoring, or explanation behavior changes.
preparedAlgorithmVersion = "2026-07-31.2"
)
type ImportResult struct {
@@ -37,6 +41,7 @@ type ImportResult struct {
Pages int `json:"pages"`
Seen int `json:"seen"`
Changed int `json:"changed"`
Dirtied int64 `json:"dirtied"`
Removed int64 `json:"removed"`
Duration time.Duration `json:"-"`
DurationMs int64 `json:"durationMs"`
@@ -51,6 +56,8 @@ type Service struct {
log *slog.Logger
minRebuildAge time.Duration
refreshAge time.Duration
location *time.Location
now func() time.Time
mu sync.Mutex
importRunning bool
@@ -74,7 +81,15 @@ func New(
return &Service{
store: st, tracearr: tracearrClient, engine: engine, log: log,
minRebuildAge: minRebuildAge, refreshAge: refreshAge,
building: map[string]bool{},
location: time.Local, now: time.Now, building: map[string]bool{},
}
}
// ConfigureTimeContext sets the household timezone used for day/time viewing habits.
func (s *Service) ConfigureTimeContext(location *time.Location) {
if location != nil {
s.location = location
s.engine.Location = location
}
}
@@ -137,6 +152,7 @@ func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, r
}()
unchangedPages := 0
terminalIdentities := map[string]store.RecommendationIdentity{}
for pageNumber := 1; ; pageNumber++ {
page, err := s.tracearr.Page(ctx, pageNumber, importPageSize)
if err != nil {
@@ -157,7 +173,7 @@ func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, r
ServerID: value.ServerID, SessionID: value.SessionID,
})
}
current, err := s.store.TracearrFingerprints(ctx, keys)
current, err := s.store.TracearrSessionSignals(ctx, keys)
if err != nil {
return result, err
}
@@ -166,10 +182,19 @@ func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, r
key := store.TracearrSessionKey{
ServerID: session.ServerID, SessionID: session.SessionID,
}
if !bytes.Equal(current[key], session.SourceFingerprint) {
previous, exists := current[key]
if !bytes.Equal(previous.Fingerprint, session.SourceFingerprint) {
pageChanged = true
result.Changed++
}
if tracearrSessionTerminal(session) && (!exists || !previous.Terminal) {
identity := store.RecommendationIdentity{
TracearrUserID: session.UserID,
Username: session.Username,
}
terminalIdentities[identity.TracearrUserID+"|"+
strings.ToLower(identity.Username)] = identity
}
}
if err := s.store.UpsertTracearrSessions(ctx, imported, started); err != nil {
return result, err
@@ -201,40 +226,45 @@ func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, r
}
result.Removed = removed
}
if result.Changed > 0 || result.Removed > 0 {
if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
return result, err
}
users, err := s.store.ActiveRecommendationUsers(ctx)
if err != nil {
return result, err
}
for _, user := range users {
if err := s.store.MarkForYouDirty(ctx, user.EmbyUserID, user.Username); err != nil {
return result, err
}
}
affected := make([]store.RecommendationIdentity, 0, len(terminalIdentities))
for _, identity := range terminalIdentities {
affected = append(affected, identity)
}
dirtied, err := s.store.MarkForYouProfilesDirtyByTracearrIdentity(ctx, affected)
if err != nil {
return result, err
}
result.Dirtied = dirtied
s.log.Info("Tracearr import finished",
"kind", result.Kind, "pages", result.Pages, "seen", result.Seen,
"changed", result.Changed, "removed", result.Removed)
"changed", result.Changed, "dirtied", result.Dirtied, "removed", result.Removed)
return result, nil
}
func tracearrSessionTerminal(session store.TracearrSession) bool {
state := strings.ToLower(strings.TrimSpace(session.State))
return session.Watched || session.StoppedAt != nil ||
state == "stopped" || state == "completed" || state == "complete" || state == "ended"
}
func (s *Service) Rebuild(ctx context.Context, sess store.Session, force bool) error {
if !s.beginBuild(sess.EmbyUserID) {
return nil
}
defer s.endBuild(sess.EmbyUserID)
_, poolBuiltAt, dirtySince, err := s.store.ForYouProfileTimes(ctx, sess.EmbyUserID)
_, poolBuiltAt, dirtySince, algorithmVersion, err :=
s.store.ForYouProfileTimes(ctx, sess.EmbyUserID)
if err != nil {
return err
}
if !force && poolBuiltAt != nil && time.Since(*poolBuiltAt) < s.minRebuildAge {
algorithmChanged := algorithmVersion != preparedAlgorithmVersion
if !force && !algorithmChanged && poolBuiltAt != nil &&
time.Since(*poolBuiltAt) < s.minRebuildAge {
return nil
}
if !force && dirtySince == nil && poolBuiltAt != nil && time.Since(*poolBuiltAt) < s.refreshAge {
if !force && !algorithmChanged && dirtySince == nil && poolBuiltAt != nil &&
time.Since(*poolBuiltAt) < s.refreshAge {
return nil
}
@@ -294,11 +324,69 @@ func (s *Service) PreparedRows(
if len(items) == 0 {
return nil, false, builtAt != nil && time.Since(*builtAt) >= s.refreshAge, nil
}
items = rankPreparedForTime(items, s.now(), s.location)
rows := buildPreparedRows(items, minutes, s.engine.MinRowItems)
stale := builtAt == nil || time.Since(*builtAt) >= s.refreshAge
return rows, len(rows) > 0, stale, nil
}
func rankPreparedForTime(
items []store.PreparedForYouItem,
now time.Time,
location *time.Location,
) []store.PreparedForYouItem {
if len(items) < 2 || len(items[0].ContextAffinity) == 0 {
return items
}
var profile recommend.ContextAffinityProfile
if err := json.Unmarshal(items[0].ContextAffinity, &profile); err != nil ||
len(profile.Slots) == 0 {
return items
}
type contextualItem struct {
item store.PreparedForYouItem
score float64
contextRaw float64
confidence float64
}
ranked := make([]contextualItem, 0, len(items))
var maxContext float64
for _, value := range items {
decoded := recommend.Decode([]json.RawMessage{value.Payload})
raw, confidence := 0.0, 0.0
if len(decoded) == 1 && value.ReasonKind != "pick-up" {
raw, confidence = profile.Score(decoded[0], now, location)
if raw > maxContext {
maxContext = raw
}
}
ranked = append(ranked, contextualItem{
item: value, score: value.BaseScore,
contextRaw: raw, confidence: confidence,
})
}
if maxContext == 0 {
return items
}
for i := range ranked {
ranked[i].score += 1.5 * ranked[i].confidence * ranked[i].contextRaw / maxContext
if ranked[i].contextRaw > 0 && ranked[i].confidence >= 0.4 {
ranked[i].item.RecommendationReason += " · fits what you watch around this time"
}
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].score != ranked[j].score {
return ranked[i].score > ranked[j].score
}
return ranked[i].item.BaseRank < ranked[j].item.BaseRank
})
out := make([]store.PreparedForYouItem, 0, len(ranked))
for _, entry := range ranked {
out = append(out, entry.item)
}
return out
}
func (s *Service) MarkDirty(ctx context.Context, sess store.Session) {
if err := s.store.MarkForYouDirty(ctx, sess.EmbyUserID, sess.Username); err != nil {
s.log.Warn("could not mark For You dirty", "user", sess.EmbyUserID, "error", err)
@@ -328,6 +416,28 @@ func (s *Service) RebuildAll(ctx context.Context, force bool) error {
return nil
}
// RebuildOutdated refreshes only profiles produced by an older algorithm. This makes
// startup migrations deterministic without rebuilding every fresh profile on every boot.
func (s *Service) RebuildOutdated(ctx context.Context) error {
users, err := s.recommendationUsers(ctx)
if err != nil {
return err
}
for _, user := range users {
_, _, _, version, stateErr := s.store.ForYouProfileTimes(ctx, user.EmbyUserID)
if stateErr != nil {
return stateErr
}
if version == preparedAlgorithmVersion {
continue
}
if err := s.Rebuild(ctx, user, true); err != nil {
s.log.Warn("outdated For You rebuild failed", "user", user.EmbyUserID, "error", err)
}
}
return nil
}
func (s *Service) MarkAllDirty(ctx context.Context) {
if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
s.log.Warn("could not mark For You profiles dirty", "error", err)
@@ -427,6 +537,15 @@ func buildPreparedRows(
if len(items) == 0 {
return nil
}
eligibleItems := make([]store.PreparedForYouItem, 0, len(items))
for _, item := range items {
if item.ReasonKind == "pick-up" &&
!strings.Contains(item.RecommendationReason, "season 1") {
continue
}
eligibleItems = append(eligibleItems, item)
}
items = eligibleItems
used := map[string]bool{}
rows := make([]recommend.Row, 0, 6)
appendRowWithMinimum := func(
@@ -465,13 +584,16 @@ func buildPreparedRows(
pickups := make([]store.PreparedForYouItem, 0, preparedRowSize)
for _, item := range items {
if item.ReasonKind == "pick-up" {
// The reason guard also keeps an already-prepared pool from an older server
// version from briefly promoting later-season lapses after deployment.
if item.ReasonKind == "pick-up" &&
strings.Contains(item.RecommendationReason, "season 1") {
pickups = append(pickups, item)
}
}
// A pickup is valuable even when only one genuinely abandoned, unfinished series
// qualifies. Unlike generic recommendations, padding this shelf would make it lie.
appendRowWithMinimum("for-you:pick-up", "Pick these up again", pickups, 1)
appendRowWithMinimum("for-you:pick-up", "Pick this show up again", pickups, 1)
topTitle := "Top picks for you"
if minutes > 0 {
@@ -542,6 +664,33 @@ func buildPreparedRows(
return strings.TrimSpace(item.ReasonGenre) != ""
},
)
beforeBed := make([]store.PreparedForYouItem, 0, len(items))
for _, item := range items {
if item.ReasonKind != "pick-up" && item.RuntimeMinutes >= 15 &&
item.RuntimeMinutes <= 50 {
decoded := recommend.Decode([]json.RawMessage{item.Payload})
if len(decoded) == 1 && strings.EqualFold(decoded[0].Type, "Series") {
beforeBed = append(beforeBed, item)
}
}
}
appendRow(
"for-you:one-episode-before-bed",
"One episode before bed",
beforeBed,
)
// The prepared pool contains only unseen candidates. Taking the remaining strong
// matches produces a genuine library-discovery shelf; request-time impression
// fatigue then rotates repeatedly ignored posters out of its leading positions.
hidden := make([]store.PreparedForYouItem, 0, len(items))
for _, item := range items {
if !used[item.ItemID] && item.ReasonKind != "pick-up" {
hidden = append(hidden, item)
}
}
appendRow("for-you:hidden", "Hidden in your library", hidden)
compatible := make([]store.PreparedForYouItem, 0, len(items))
for _, item := range items {
if !used[item.ItemID] && item.CompatibilityScore > 0.2 {
@@ -569,48 +718,62 @@ func rowKey(value string) string {
func (s *Service) Schedule(
ctx context.Context,
importEvery, fullEvery, refreshEvery time.Duration,
importEvery, fullEvery time.Duration,
rebuildHour int,
) {
if importEvery <= 0 {
var importC <-chan time.Time
var importTicker *time.Ticker
if importEvery > 0 {
importTicker = time.NewTicker(importEvery)
importC = importTicker.C
defer importTicker.Stop()
} else {
s.log.Info("Tracearr auto-import disabled")
return
}
importTicker := time.NewTicker(importEvery)
defer importTicker.Stop()
var fullC, refreshC <-chan time.Time
var fullTicker, refreshTicker *time.Ticker
var fullC <-chan time.Time
var fullTicker *time.Ticker
if fullEvery > 0 {
fullTicker = time.NewTicker(fullEvery)
fullC = fullTicker.C
defer fullTicker.Stop()
}
if refreshEvery > 0 {
refreshTicker = time.NewTicker(refreshEvery)
refreshC = refreshTicker.C
defer refreshTicker.Stop()
}
nextRebuild := nextDailyRebuild(time.Now(), s.location, rebuildHour)
rebuildTimer := time.NewTimer(time.Until(nextRebuild))
defer rebuildTimer.Stop()
s.log.Info("For You daily rebuild scheduled", "next", nextRebuild)
for {
select {
case <-ctx.Done():
return
case <-importTicker.C:
case <-importC:
if _, err := s.Import(ctx, false); err != nil {
s.log.Warn("scheduled Tracearr import failed", "error", err)
} else {
_ = s.RebuildAll(ctx, false)
}
case <-fullC:
if _, err := s.Import(ctx, true); err != nil {
s.log.Warn("scheduled full Tracearr import failed", "error", err)
} else {
_ = s.RebuildAll(ctx, false)
}
case <-refreshC:
_ = s.RebuildAll(ctx, false)
case <-rebuildTimer.C:
if err := s.RebuildAll(ctx, true); err != nil {
s.log.Warn("scheduled daily For You rebuild failed", "error", err)
}
rebuildTimer.Reset(time.Until(nextDailyRebuild(time.Now(), s.location, rebuildHour)))
}
}
}
func nextDailyRebuild(now time.Time, location *time.Location, hour int) time.Time {
if location == nil {
location = time.UTC
}
local := now.In(location)
next := time.Date(local.Year(), local.Month(), local.Day(), hour, 0, 0, 0, location)
if !next.After(local) {
next = next.AddDate(0, 0, 1)
}
return next
}
func (s *Service) beginBuild(userID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
@@ -706,6 +869,14 @@ func storedResult(
if err != nil {
return store.RecommendationProfile{}, nil, err
}
contextAffinity, err := json.Marshal(result.Profile.ContextAffinity)
if err != nil {
return store.RecommendationProfile{}, nil, err
}
weightedProfile, err := json.Marshal(result.Profile.Weighted)
if err != nil {
return store.RecommendationProfile{}, nil, err
}
profile := store.RecommendationProfile{
EmbyUserID: userID, TracearrUserID: result.Profile.TracearrUserID,
TracearrUsername: result.Profile.TracearrUsername,
@@ -713,10 +884,14 @@ func storedResult(
MeanCompletionRatio: result.Profile.MeanCompletionRatio,
TypicalSessionMinutes: result.Profile.TypicalSessionMinutes,
GenreAffinity: genre, TitleAffinity: title, StudioAffinity: studio,
CodecOutcomes: codecs, SignalsThrough: result.Profile.SignalsThrough, BuiltAt: builtAt,
ContextAffinity: contextAffinity, CodecOutcomes: codecs,
WeightedProfile: weightedProfile,
AlgorithmVersion: preparedAlgorithmVersion,
SignalsThrough: result.Profile.SignalsThrough, BuiltAt: builtAt,
}
candidates := make([]store.ForYouCandidate, 0, len(result.Candidates))
for _, value := range result.Candidates {
candidateCount := min(len(result.Candidates), maxPreparedCandidates)
candidates := make([]store.ForYouCandidate, 0, candidateCount)
for _, value := range result.Candidates[:candidateCount] {
candidates = append(candidates, store.ForYouCandidate{
ItemID: value.ItemID, BaseRank: value.BaseRank, BaseScore: value.BaseScore,
RuntimeMinutes: value.RuntimeMinutes, AffinityScore: value.AffinityScore,
+108 -7
View File
@@ -4,11 +4,58 @@ import (
"bytes"
"encoding/json"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/tracearr"
)
func TestRankPreparedForTimeMovesTypicalPosterAheadWithinRelevantPool(t *testing.T) {
location := time.UTC
contextProfile := recommend.NewContextAffinityProfile()
comedyHistory := recommend.Item{Genres: []string{"Comedy"}}
for i := 0; i < 5; i++ {
contextProfile.Add(
comedyHistory,
time.Date(2026, 6, 29+i*7, 20, 0, 0, 0, location),
1,
i,
location,
)
}
rawContext, err := json.Marshal(contextProfile)
if err != nil {
t.Fatal(err)
}
items := []store.PreparedForYouItem{
{
ItemID: "drama", BaseRank: 1, BaseScore: 5,
Payload: json.RawMessage(`{"Id":"drama","Genres":["Drama"]}`),
ContextAffinity: rawContext,
},
{
ItemID: "comedy", BaseRank: 2, BaseScore: 4.8,
Payload: json.RawMessage(`{"Id":"comedy","Genres":["Comedy"]}`),
RecommendationReason: "Matches your viewing",
ContextAffinity: rawContext,
},
}
ranked := rankPreparedForTime(
items,
time.Date(2026, 7, 27, 20, 0, 0, 0, location),
location,
)
if ranked[0].ItemID != "comedy" {
t.Fatalf("first contextual poster = %q, want comedy", ranked[0].ItemID)
}
if ranked[0].RecommendationReason !=
"Matches your viewing · fits what you watch around this time" {
t.Fatalf("context reason = %q", ranked[0].RecommendationReason)
}
}
func TestImportedSessionUsesStableKeyAndRecommendationFingerprint(t *testing.T) {
session := tracearr.Session{
ID: "session-1", MediaType: "movie", MediaTitle: "Arrival",
@@ -35,6 +82,52 @@ func TestImportedSessionUsesStableKeyAndRecommendationFingerprint(t *testing.T)
}
}
func TestTracearrSessionTerminalRequiresCompletedState(t *testing.T) {
active := store.TracearrSession{State: "playing"}
if tracearrSessionTerminal(active) {
t.Fatal("active session was terminal")
}
stopped := active
stopped.State = "stopped"
if !tracearrSessionTerminal(stopped) {
t.Fatal("stopped session was not terminal")
}
watched := active
watched.Watched = true
if !tracearrSessionTerminal(watched) {
t.Fatal("watched session was not terminal")
}
}
func TestNextDailyRebuildUsesConfiguredLocalHour(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 31, 5, 30, 0, 0, location)
next := nextDailyRebuild(now, location, 4)
want := time.Date(2026, 8, 1, 4, 0, 0, 0, location)
if !next.Equal(want) {
t.Fatalf("next rebuild = %v, want %v", next, want)
}
}
func TestStoredResultCapsCandidatePoolAndPersistsAlgorithmVersion(t *testing.T) {
result := recommend.PreparedResult{
Candidates: make([]recommend.PreparedCandidate, maxPreparedCandidates+50),
}
for i := range result.Candidates {
result.Candidates[i].ItemID = itoaForTest(i + 1)
}
profile, candidates, err := storedResult("user", time.Now(), result)
if err != nil {
t.Fatal(err)
}
if len(candidates) != maxPreparedCandidates {
t.Fatalf("candidate count = %d, want %d", len(candidates), maxPreparedCandidates)
}
if profile.AlgorithmVersion != preparedAlgorithmVersion {
t.Fatalf("algorithm version = %q", profile.AlgorithmVersion)
}
}
func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T) {
items := make([]store.PreparedForYouItem, 0, 120)
for i := 0; i < 120; i++ {
@@ -84,15 +177,23 @@ func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T)
}
func TestBuildPreparedRowsKeepsASingleGenuinePickup(t *testing.T) {
rows := buildPreparedRows([]store.PreparedForYouItem{{
ItemID: "show-1", BaseRank: 1,
Payload: json.RawMessage(`{"Id":"show-1"}`),
ReasonKind: "pick-up",
RecommendationReason: "You left this in season 1 · pick it up again",
}}, 0, 4)
rows := buildPreparedRows([]store.PreparedForYouItem{
{
ItemID: "show-1", BaseRank: 1,
Payload: json.RawMessage(`{"Id":"show-1"}`),
ReasonKind: "pick-up",
RecommendationReason: "You left this in season 1 · pick it up again",
},
{
ItemID: "show-2", BaseRank: 2,
Payload: json.RawMessage(`{"Id":"show-2"}`),
ReasonKind: "pick-up",
RecommendationReason: "You made it through season 2 · season 3 is waiting",
},
}, 0, 4)
if len(rows) != 1 || rows[0].ID != "for-you:pick-up" ||
rows[0].Title != "Pick these up again" || len(rows[0].Items) != 1 {
rows[0].Title != "Pick this show up again" || len(rows[0].Items) != 1 {
t.Fatalf("pickup rows = %+v", rows)
}
}
+11 -7
View File
@@ -37,7 +37,8 @@ const (
// syncFields is everything the gateway serves or filters on. Images are requested as
// tags only — the artwork itself is proxied on demand.
syncFields = "Genres,Studios,Overview,Taglines,ProductionYear,CommunityRating,OfficialRating," +
"RunTimeTicks,SeriesName,PrimaryImageAspectRatio,DateCreated,MediaStreams"
"RunTimeTicks,SeriesName,PrimaryImageAspectRatio,DateCreated,PremiereDate,People," +
"CollectionName,MediaStreams,RecursiveItemCount"
syncImageTypes = "Backdrop,Primary,Logo,Thumb"
syncItemTypes = "Movie,Series,Episode"
@@ -58,7 +59,7 @@ type Syncer struct {
mu sync.Mutex
running bool
afterSync func()
afterSync func(Result)
}
func NewSyncer(embyClient *emby.Client, st *store.Store, serviceCred emby.Credentials, log *slog.Logger) *Syncer {
@@ -66,7 +67,7 @@ func NewSyncer(embyClient *emby.Client, st *store.Store, serviceCred emby.Creden
}
// SetAfterSync installs the inexpensive invalidation callback used by derived data.
func (s *Syncer) SetAfterSync(callback func()) {
func (s *Syncer) SetAfterSync(callback func(Result)) {
s.mu.Lock()
defer s.mu.Unlock()
s.afterSync = callback
@@ -77,6 +78,7 @@ type Result struct {
Kind string `json:"kind"`
Seen int `json:"seen"`
Upserted int `json:"upserted"`
Changed int `json:"changed"`
Removed int `json:"removed"`
Duration time.Duration `json:"-"`
DurationMs int64 `json:"durationMs"`
@@ -156,13 +158,13 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error)
}
s.log.Info("library sync finished",
"kind", kind, "trigger", trigger, "seen", result.Seen,
"upserted", result.Upserted, "removed", result.Removed,
"upserted", result.Upserted, "changed", result.Changed, "removed", result.Removed,
"duration", result.Duration.Round(time.Millisecond))
s.mu.Lock()
afterSync := s.afterSync
s.mu.Unlock()
if afterSync != nil {
afterSync()
afterSync(result)
}
return result, nil
}
@@ -213,17 +215,19 @@ func (s *Syncer) run(
items = append(items, item)
}
}
written, err := s.store.UpsertLibraryItems(ctx, items, syncedAt)
changed, err := s.store.UpsertLibraryItems(ctx, items, syncedAt)
if err != nil {
return result, err
}
result.Seen += len(page.Items)
result.Upserted += int(written)
result.Upserted += len(items)
result.Changed += int(changed)
s.log.Info("library sync progress",
"kind", kind,
"seen", result.Seen,
"upserted", result.Upserted,
"changed", result.Changed,
)
if len(page.Items) < pageSize {
+20 -7
View File
@@ -56,15 +56,25 @@ type EventPage struct {
}
// Buffer is a bounded, concurrency-safe ring of recent structured log records.
// Bounding is important: a broken TV can generate traffic indefinitely, while 20,000
// records is still enough context for an operator to inspect a sustained incident.
// Bounding is important: a broken TV can generate traffic indefinitely. The configured
// ring retains enough context for an operator without growing or shifting on every write.
type Buffer struct {
mu sync.RWMutex
capacity int
events []Event
start int
next atomic.Int64
}
// ParseCapacity returns a non-negative log buffer capacity from configuration.
func ParseCapacity(value string, fallback int) int {
capacity, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || capacity < 0 {
return fallback
}
return capacity
}
// NewBuffered writes normal text logs and mirrors accepted records into a ring buffer.
func NewBuffered(w io.Writer, level slog.Leveler, capacity int) (*slog.Logger, *Buffer) {
options := &slog.HandlerOptions{
@@ -158,8 +168,8 @@ func (b *Buffer) append(event Event) {
b.mu.Lock()
defer b.mu.Unlock()
if len(b.events) == b.capacity {
copy(b.events, b.events[1:])
b.events[len(b.events)-1] = event
b.events[b.start] = event
b.start = (b.start + 1) % b.capacity
return
}
b.events = append(b.events, event)
@@ -182,20 +192,23 @@ func (b *Buffer) Events(after int64, limit int) EventPage {
if len(b.events) == 0 {
return page
}
page.Oldest = b.events[0].Sequence
page.Oldest = b.events[b.start].Sequence
if after < page.Oldest-1 {
page.Dropped = page.Oldest - after - 1
after = page.Oldest - 1
}
start := len(b.events)
for i := range b.events {
if b.events[i].Sequence > after {
event := b.events[(b.start+i)%len(b.events)]
if event.Sequence > after {
start = i
break
}
}
end := min(start+limit, len(b.events))
page.Events = append(page.Events, b.events[start:end]...)
for i := start; i < end; i++ {
page.Events = append(page.Events, b.events[(b.start+i)%len(b.events)])
}
if len(page.Events) > 0 {
page.Next = page.Events[len(page.Events)-1].Sequence
}
+9
View File
@@ -56,3 +56,12 @@ func TestParseLevel(t *testing.T) {
}
}
}
func TestParseCapacity(t *testing.T) {
if got := ParseCapacity("1000", 5); got != 1000 {
t.Fatalf("capacity = %d", got)
}
if got := ParseCapacity("-1", 5); got != 5 {
t.Fatalf("negative capacity = %d", got)
}
}
+218
View File
@@ -0,0 +1,218 @@
// Package radarr provides the small read-only slice of Radarr used by the home screen.
package radarr
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type Client struct {
baseURL string
apiKey string
http *http.Client
}
type Image struct {
CoverType string `json:"coverType"`
URL string `json:"url"`
RemoteURL string `json:"remoteUrl"`
}
type MovieFile struct {
DateAdded *time.Time `json:"dateAdded"`
}
type Movie struct {
ID int `json:"id"`
TMDBID int `json:"tmdbId"`
Title string `json:"title"`
TitleSlug string `json:"titleSlug"`
Overview string `json:"overview"`
Year int `json:"year"`
Runtime int `json:"runtime"`
Genres []string `json:"genres"`
Images []Image `json:"images"`
DigitalRelease *time.Time `json:"digitalRelease"`
PhysicalRelease *time.Time `json:"physicalRelease"`
InCinemas *time.Time `json:"inCinemas"`
HasFile bool `json:"hasFile"`
Monitored bool `json:"monitored"`
MovieFile *MovieFile `json:"movieFile"`
RootFolderPath string `json:"rootFolderPath,omitempty"`
QualityProfileID int `json:"qualityProfileId,omitempty"`
MinimumAvailability string `json:"minimumAvailability,omitempty"`
}
type RootFolder struct {
Path string `json:"path"`
}
type QualityProfile struct {
ID int `json:"id"`
}
type APIError struct {
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("radarr: status %d: %s", e.StatusCode, e.Body)
}
func New(baseURL, apiKey string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
// Calendar returns movies whose Radarr calendar dates intersect [start, end). Memby
// selects actual digital releases and its cinema-date fallback after fetching the data.
func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Movie, error) {
params := url.Values{
"start": {start.UTC().Format(time.RFC3339Nano)},
"end": {end.UTC().Format(time.RFC3339Nano)},
"unmonitored": {"true"},
}
req, err := c.request(ctx, "/api/v3/calendar", params)
if err != nil {
return nil, err
}
var movies []Movie
if err := c.do(req, &movies); err != nil {
return nil, err
}
return movies, nil
}
func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) {
req, err := c.request(ctx, "/api/v3/movie/lookup", url.Values{"term": {term}})
if err != nil {
return nil, err
}
var movies []Movie
if err := c.do(req, &movies); err != nil {
return nil, err
}
return movies, nil
}
// AddUnmonitored adds a title without starting a search or monitoring future releases.
func (c *Client) AddUnmonitored(ctx context.Context, movie Movie) (Movie, error) {
var roots []RootFolder
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
return Movie{}, err
}
var profiles []QualityProfile
if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil {
return Movie{}, err
}
if len(roots) == 0 || len(profiles) == 0 {
return Movie{}, fmt.Errorf("radarr: no root folder or quality profile configured")
}
movie.ID = 0
movie.RootFolderPath = roots[0].Path
movie.QualityProfileID = profiles[0].ID
movie.Monitored = false
body := struct {
Movie
AddOptions map[string]bool `json:"addOptions"`
}{Movie: movie, AddOptions: map[string]bool{"searchForMovie": false}}
var added Movie
if err := c.post(ctx, "/api/v3/movie", body, &added); err != nil {
return Movie{}, err
}
return added, nil
}
// MediaCover fetches a movie poster or fanart without exposing the Radarr API key.
func (c *Client) MediaCover(ctx context.Context, movieID int, coverType string) (*http.Response, error) {
if movieID <= 0 || (coverType != "poster" && coverType != "fanart") {
return nil, fmt.Errorf("radarr: invalid media cover")
}
path := "/MediaCover/" + strconv.Itoa(movieID) + "/" + coverType + ".jpg"
req, err := c.request(ctx, path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "image/*")
resp, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("radarr: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return nil, &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
return resp, nil
}
func (c *Client) request(ctx context.Context, path string, params url.Values) (*http.Request, error) {
endpoint := c.baseURL + path
if len(params) > 0 {
endpoint += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Api-Key", c.apiKey)
req.Header.Set("Accept", "application/json")
return req, nil
}
func (c *Client) get(ctx context.Context, path string, out any) error {
req, err := c.request(ctx, path, nil)
if err != nil {
return err
}
return c.do(req, out)
}
func (c *Client) post(ctx context.Context, path string, body, out any) error {
raw, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("radarr: encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, strings.NewReader(string(raw)))
if err != nil {
return err
}
req.Header.Set("X-Api-Key", c.apiKey)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
return c.do(req, out)
}
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("radarr: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("radarr: decode response: %w", err)
}
return nil
}
+87
View File
@@ -0,0 +1,87 @@
package radarr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
var gotQuery string
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v3/calendar" {
t.Errorf("path = %q", r.URL.Path)
}
if got := r.Header.Get("X-Api-Key"); got != "secret" {
t.Errorf("X-Api-Key = %q", got)
}
if r.URL.Query().Get("unmonitored") != "true" {
t.Errorf("missing unmonitored flag: %s", r.URL.RawQuery)
}
if got := r.URL.Query().Get("start"); got != "2026-07-01T00:00:00Z" {
t.Errorf("start = %q", got)
}
gotQuery = r.URL.RawQuery
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[{"id":7,"title":"Arrival","digitalRelease":"2026-08-01T00:00:00Z"}]`))
}))
defer upstream.Close()
client := New(upstream.URL, "secret", time.Second)
movies, err := client.Calendar(
context.Background(),
time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC),
time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC),
)
if err != nil {
t.Fatal(err)
}
if len(movies) != 1 || movies[0].ID != 7 || movies[0].DigitalRelease == nil {
t.Fatalf("unexpected movies: %+v", movies)
}
if gotQuery == "" || strings.Contains(gotQuery, "secret") {
t.Fatalf("API key leaked into query: %q", gotQuery)
}
}
func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/rootfolder":
_, _ = w.Write([]byte(`[{"path":"/movies"}]`))
case "/api/v3/qualityprofile":
_, _ = w.Write([]byte(`[{"id":4}]`))
case "/api/v3/movie":
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["monitored"] != false || body["rootFolderPath"] != "/movies" ||
body["qualityProfileId"] != float64(4) {
t.Errorf("unexpected add body: %#v", body)
}
options := body["addOptions"].(map[string]any)
if options["searchForMovie"] != false {
t.Errorf("movie search was enabled: %#v", body)
}
_, _ = w.Write([]byte(`{"id":9,"tmdbId":22,"title":"Arrival"}`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer upstream.Close()
added, err := New(upstream.URL, "secret", time.Second).AddUnmonitored(
context.Background(), Movie{TMDBID: 22, Title: "Arrival", Monitored: true},
)
if err != nil {
t.Fatal(err)
}
if added.ID != 9 {
t.Fatalf("unexpected movie: %+v", added)
}
}
+230
View File
@@ -0,0 +1,230 @@
package recommend
import (
"math"
"strings"
"time"
)
// ContextAffinityProfile captures what a viewer tends to watch in broad local-time
// windows. It is intentionally compact: seven weekdays by four day parts is enough to
// learn household routines without pretending that a small history is a precise model.
type ContextAffinityProfile struct {
Slots map[string]ContextAffinityBucket `json:"slots,omitempty"`
}
type ContextAffinityBucket struct {
Samples int `json:"samples"`
GenreWeights map[string]float64 `json:"genres,omitempty"`
StudioWeights map[string]float64 `json:"studios,omitempty"`
}
func NewContextAffinityProfile() ContextAffinityProfile {
return ContextAffinityProfile{Slots: map[string]ContextAffinityBucket{}}
}
// Add records one matched Tracearr session. Completion and recency determine how much
// taste evidence it contributes, while Samples controls confidence separately.
func (p *ContextAffinityProfile) Add(
item Item,
started time.Time,
completion float64,
recencyPosition int,
location *time.Location,
) {
if started.IsZero() {
return
}
if p.Slots == nil {
p.Slots = map[string]ContextAffinityBucket{}
}
if location == nil {
location = time.Local
}
local := started.In(location)
key := contextSlotKey(local.Weekday(), dayPart(local.Hour()))
bucket := p.Slots[key]
if bucket.GenreWeights == nil {
bucket.GenreWeights = map[string]float64{}
}
if bucket.StudioWeights == nil {
bucket.StudioWeights = map[string]float64{}
}
bucket.Samples++
weight := (0.2 + clamp01(completion)) * math.Pow(0.985, float64(recencyPosition))
for _, genre := range item.Genres {
if genre = strings.TrimSpace(genre); genre != "" {
bucket.GenreWeights[genre] += weight
}
}
for _, studio := range item.Studios {
if name := strings.TrimSpace(studio.Name); name != "" {
bucket.StudioWeights[name] += weight * 0.4
}
}
p.Slots[key] = bucket
}
// Score returns a bounded contextual affinity source score and its confidence. Exact
// weekday/time behavior matters most; neighboring time windows and the same time on
// other days provide progressively weaker fallbacks. No match simply returns zero.
func (p ContextAffinityProfile) Score(
item Item,
now time.Time,
location *time.Location,
) (float64, float64) {
if len(p.Slots) == 0 {
return 0, 0
}
if location == nil {
location = time.Local
}
local := now.In(location)
part := dayPart(local.Hour())
var score, sampleWeight float64
for key, bucket := range p.Slots {
weekday, bucketPart, ok := parseContextSlotKey(key)
if !ok {
continue
}
weight := contextSlotSimilarity(local.Weekday(), part, weekday, bucketPart)
if weight == 0 {
continue
}
var affinity float64
for _, genre := range item.Genres {
affinity += weightFold(bucket.GenreWeights, genre)
}
if n := len(item.Genres); n > 1 {
affinity /= math.Sqrt(float64(n))
}
for _, studio := range item.Studios {
affinity += weightFold(bucket.StudioWeights, studio.Name)
}
score += affinity * weight
sampleWeight += float64(bucket.Samples) * weight
}
if sampleWeight == 0 {
return 0, 0
}
// Five effective sessions are enough for the full (still bounded) contextual nudge.
return score / sampleWeight, math.Min(1, sampleWeight/5)
}
// Contextualized returns a copy of the base profile with a modest current-time taste
// nudge. It is used by dynamic shelves; the prepared For You pool applies the same
// signal directly to candidate placement.
func (p ContextAffinityProfile) Contextualized(
base Profile,
now time.Time,
location *time.Location,
) Profile {
out := base
out.GenreWeights = cloneWeights(base.GenreWeights)
out.StudioWeights = cloneWeights(base.StudioWeights)
probe := Item{Genres: keysFromWeights(out.GenreWeights)}
_, confidence := p.Score(probe, now, location)
if confidence == 0 {
return out
}
local := now
if location != nil {
local = now.In(location)
}
part := dayPart(local.Hour())
for key, bucket := range p.Slots {
weekday, bucketPart, ok := parseContextSlotKey(key)
if !ok {
continue
}
similarity := contextSlotSimilarity(local.Weekday(), part, weekday, bucketPart)
if similarity == 0 {
continue
}
scale := 0.35 * confidence * similarity / math.Max(1, float64(bucket.Samples))
for genre, weight := range bucket.GenreWeights {
out.GenreWeights[genre] += weight * scale
}
for studio, weight := range bucket.StudioWeights {
out.StudioWeights[studio] += weight * scale
}
}
return out
}
func contextSlotSimilarity(currentDay time.Weekday, currentPart int, day time.Weekday, part int) float64 {
dayDistance := int(currentDay) - int(day)
if dayDistance < 0 {
dayDistance = -dayDistance
}
if dayDistance > 3 {
dayDistance = 7 - dayDistance
}
partDistance := currentPart - part
if partDistance < 0 {
partDistance = -partDistance
}
switch {
case dayDistance == 0 && partDistance == 0:
return 1
case dayDistance == 0 && partDistance == 1:
return 0.35
case dayDistance == 1 && partDistance == 0:
return 0.25
case partDistance == 0:
return 0.12
default:
return 0
}
}
func dayPart(hour int) int {
switch {
case hour >= 5 && hour < 11:
return 0
case hour >= 11 && hour < 17:
return 1
case hour >= 17 && hour < 22:
return 2
default:
return 3
}
}
func contextSlotKey(day time.Weekday, part int) string {
return string(rune('0'+day)) + ":" + string(rune('0'+part))
}
func parseContextSlotKey(key string) (time.Weekday, int, bool) {
if len(key) != 3 || key[1] != ':' || key[0] < '0' || key[0] > '6' ||
key[2] < '0' || key[2] > '3' {
return 0, 0, false
}
return time.Weekday(key[0] - '0'), int(key[2] - '0'), true
}
func clamp01(value float64) float64 {
if value < 0 {
return 0
}
if value > 1 {
return 1
}
return value
}
func cloneWeights(source map[string]float64) map[string]float64 {
out := make(map[string]float64, len(source))
for key, value := range source {
out[key] = value
}
return out
}
func keysFromWeights(source map[string]float64) []string {
out := make([]string, 0, len(source))
for key := range source {
out = append(out, key)
}
return out
}
+82
View File
@@ -0,0 +1,82 @@
package recommend
import (
"testing"
"time"
)
func TestContextAffinityPrefersTypicalWeekdayTime(t *testing.T) {
location := time.FixedZone("test", 12*60*60)
profile := NewContextAffinityProfile()
comedy := Item{Genres: []string{"Comedy"}}
drama := Item{Genres: []string{"Drama"}}
for i := 0; i < 6; i++ {
profile.Add(
comedy,
time.Date(2026, 7, 6+i*7, 19, 30, 0, 0, location),
1,
i,
location,
)
profile.Add(
drama,
time.Date(2026, 7, 7+i*7, 13, 0, 0, 0, location),
1,
i,
location,
)
}
now := time.Date(2026, 7, 27, 20, 0, 0, 0, location) // Monday evening.
comedyScore, confidence := profile.Score(comedy, now, location)
dramaScore, _ := profile.Score(drama, now, location)
if comedyScore <= dramaScore {
t.Fatalf("Monday-evening comedy score %.3f <= drama score %.3f", comedyScore, dramaScore)
}
if confidence != 1 {
t.Fatalf("confidence = %.3f, want 1 after repeated matching sessions", confidence)
}
}
func TestContextAffinityUsesNeighboringWindowsAsWeakFallback(t *testing.T) {
location := time.UTC
profile := NewContextAffinityProfile()
item := Item{Genres: []string{"Documentary"}}
profile.Add(
item,
time.Date(2026, 7, 20, 16, 30, 0, 0, location), // Monday afternoon.
0.8,
0,
location,
)
exact, exactConfidence := profile.Score(
item, time.Date(2026, 7, 27, 16, 0, 0, 0, location), location,
)
neighbor, neighborConfidence := profile.Score(
item, time.Date(2026, 7, 27, 18, 0, 0, 0, location), location,
)
if neighbor <= 0 || neighbor*neighborConfidence >= exact*exactConfidence {
t.Fatalf(
"weighted neighbor %.3f should be positive and below exact %.3f",
neighbor*neighborConfidence,
exact*exactConfidence,
)
}
if neighborConfidence >= exactConfidence {
t.Fatalf(
"neighbor confidence %.3f should be below exact %.3f",
neighborConfidence,
exactConfidence,
)
}
}
func TestContextAffinityHasNeutralSparseHistoryFallback(t *testing.T) {
score, confidence := (ContextAffinityProfile{}).Score(
Item{Genres: []string{"Drama"}}, time.Now(), time.UTC,
)
if score != 0 || confidence != 0 {
t.Fatalf("empty profile = score %.3f confidence %.3f, want neutral", score, confidence)
}
}
+266 -30
View File
@@ -3,6 +3,7 @@ package recommend
import (
"context"
"encoding/json"
"hash/fnv"
"log/slog"
"math"
"net/url"
@@ -54,6 +55,12 @@ type CuratedLibrarySource interface {
) ([]json.RawMessage, error)
}
// GenreLibrarySource lets an imported Sonarr/Radarr/Emby catalogue describe itself.
// It is optional so recommendation sources that predate genre discovery still work.
type GenreLibrarySource interface {
LibraryGenres(ctx context.Context, itemTypes []string, minItems int) ([]string, error)
}
type TracearrSource interface {
History(ctx context.Context, username string, limit int) ([]tracearr.Session, error)
}
@@ -96,6 +103,12 @@ type Engine struct {
MaxSimilarRows int
RowSize int
CuratedRows []CuratedRow
WeightedConfig WeightedConfig
// Location defines the viewer's local day/time windows. Now is injectable so
// contextual placement stays deterministic in tests.
Location *time.Location
Now func() time.Time
}
// ForYouOptions are request-scoped constraints chosen on the television.
@@ -125,12 +138,13 @@ func (e *Engine) BuildForYou(
profile := BuildProfile(history, favorites)
var sessions []tracearr.Session
contextAffinity := NewContextAffinityProfile()
if e.Tracearr != nil {
if fetched, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
e.log.Warn("tracearr history unavailable; using emby signals", "error", traceErr)
} else {
sessions = recommendationSessions(fetched)
e.applyTracearrSignals(&profile, history, sessions)
contextAffinity = e.applyTracearrSignals(&profile, history, sessions)
}
}
@@ -163,7 +177,10 @@ func (e *Engine) BuildForYou(
}
compatibility := buildCompatibilityProfile(sessions)
items := rankForYou(profile, candidates, options.AvailableMinutes, compatibility, e.RowSize)
items := rankForYouAt(
profile, candidates, options.AvailableMinutes, compatibility, e.RowSize,
contextAffinity, e.now(), e.Location,
)
if len(items) < e.MinRowItems {
return []Row{}, nil
}
@@ -199,7 +216,8 @@ func (e *Engine) applyTracearrSignals(
profile *Profile,
history []Item,
sessions []tracearr.Session,
) {
) ContextAffinityProfile {
contextAffinity := NewContextAffinityProfile()
byTitle := make(map[string]Item, len(history))
for _, item := range history {
byTitle[item.TitleKey()] = item
@@ -216,7 +234,11 @@ func (e *Engine) applyTracearrSignals(
// minutes"; recency lets changing tastes move promptly.
weight := (0.2 + session.Completion()) * powDecay(0.985, i)
profile.absorbTaste(item, weight)
if started, ok := parseTracearrTime(session.StartedAt); ok {
contextAffinity.Add(item, started, session.Completion(), i, e.Location)
}
}
return contextAffinity
}
func (e *Engine) libraryCandidatesForYou(
@@ -307,13 +329,32 @@ func rankForYou(
availableMinutes int,
compatibility compatibilityProfile,
limit int,
) []Item {
return rankForYouAt(
profile, candidates, availableMinutes, compatibility, limit,
ContextAffinityProfile{}, time.Time{}, nil,
)
}
func rankForYouAt(
profile Profile,
candidates []Item,
availableMinutes int,
compatibility compatibilityProfile,
limit int,
contextAffinity ContextAffinityProfile,
now time.Time,
location *time.Location,
) []Item {
type scored struct {
item Item
score float64
item Item
score float64
contextRaw float64
confidence float64
}
ranked := make([]scored, 0, len(candidates))
seen := map[string]bool{}
var maxContext float64
for _, candidate := range candidates {
if seen[candidate.ID] {
continue
@@ -333,7 +374,20 @@ func rankForYou(
// allowing runtime to overwhelm taste.
score += float64(runtime) / float64(availableMinutes) * 0.35
}
ranked = append(ranked, scored{item: candidate, score: score})
contextRaw, confidence := contextAffinity.Score(candidate, now, location)
if contextRaw > maxContext {
maxContext = contextRaw
}
ranked = append(ranked, scored{
item: candidate, score: score, contextRaw: contextRaw, confidence: confidence,
})
}
if maxContext > 0 {
for i := range ranked {
// A maximum 1.5-point lift is enough to rearrange similarly relevant
// posters without allowing a routine to overpower taste or quality.
ranked[i].score += 1.5 * ranked[i].confidence * ranked[i].contextRaw / maxContext
}
}
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].score != ranked[j].score {
@@ -351,6 +405,13 @@ func rankForYou(
return out
}
func (e *Engine) now() time.Time {
if e.Now != nil {
return e.Now()
}
return time.Now()
}
func explainRecommendation(
profile Profile,
item Item,
@@ -432,6 +493,7 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
MinRowItems: 4,
MaxSimilarRows: 2,
RowSize: 20,
WeightedConfig: DefaultWeightedConfig(),
CuratedRows: []CuratedRow{
{
ID: "curated:apple-tv",
@@ -518,8 +580,8 @@ func movieStudioRow(id, title string, studios ...string) CuratedRow {
}
const (
historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks"
candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
historyFields = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,SeriesName,ProductionYear,PremiereDate,RunTimeTicks"
candidateFields = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
rowImageTypes = "Backdrop,Primary,Logo"
)
@@ -528,12 +590,45 @@ const (
// Cost is a handful of Emby queries, which is why callers cache the result rather than
// computing it on every home load.
func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, error) {
return e.BuildRowsForUser(ctx, cred, "")
}
// BuildRowsForUser combines Emby state with Tracearr playback outcomes, Memby's own
// browsing events and the imported Sonarr/Radarr-backed catalogue. BuildRows remains as
// the compatibility entry point for tests and callers that do not know a username.
func (e *Engine) BuildRowsForUser(
ctx context.Context,
cred emby.Credentials,
username string,
) ([]Row, error) {
history, favorites, err := e.gatherSignals(ctx, cred)
if err != nil {
return nil, err
}
profile := BuildProfile(history, favorites)
contextAffinity := NewContextAffinityProfile()
if e.Tracearr != nil && strings.TrimSpace(username) != "" {
if sessions, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
e.log.Warn("tracearr signals unavailable for shelves", "error", traceErr)
} else {
contextAffinity = e.applyTracearrSignals(
&profile, history, recommendationSessions(sessions),
)
}
}
if e.Behavior != nil {
if raws, browseErr := e.Behavior.BrowsingCandidates(
ctx, cred.UserID, time.Now().Add(-30*24*time.Hour), 40,
); browseErr != nil {
e.log.Warn("browsing signals unavailable for shelves", "error", browseErr)
} else {
for i, item := range Decode(raws) {
profile.absorbTaste(item, 0.55*powDecay(0.92, i))
}
}
}
profile = contextAffinity.Contextualized(profile, e.now(), e.Location)
rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
if !profile.IsEmpty() {
@@ -548,22 +643,26 @@ func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, e
rows = append(rows, row)
}
}
rows = append(rows, e.buildCuratedRows(ctx, profile)...)
rows = append(rows, e.buildCuratedRows(ctx, profile, dailySeed(cred.UserID))...)
return rows, nil
}
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile, seed string) []Row {
library, ok := e.Library.(CuratedLibrarySource)
if !ok {
return nil
}
curatedDefinitions := append([]CuratedRow(nil), e.CuratedRows...)
if genres, supportsGenres := e.Library.(GenreLibrarySource); supportsGenres {
curatedDefinitions = e.catalogueGenreRows(ctx, genres, curatedDefinitions)
}
type rankedDefinition struct {
definition CuratedRow
affinity float64
order int
}
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
for order, definition := range e.CuratedRows {
definitions := make([]rankedDefinition, 0, len(curatedDefinitions))
for order, definition := range curatedDefinitions {
affinity := profile.CollectionAffinity(definition.Genres, definition.Studios)
// Studio signals are intentionally damped while scoring individual titles.
// Restore enough weight at shelf level for a genuinely followed studio to earn
@@ -585,6 +684,11 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
if definitions[i].affinity != definitions[j].affinity {
return definitions[i].affinity > definitions[j].affinity
}
left := stableVariation(seed, definitions[i].definition.ID)
right := stableVariation(seed, definitions[j].definition.ID)
if left != right {
return left < right
}
return definitions[i].order < definitions[j].order
})
@@ -592,20 +696,8 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
// A series may carry several genres. Give it to the user's highest-affinity shelf
// only, so scrolling Shows never reveals the same card again under another label.
seenItems := make(map[string]struct{})
movieGenreRows := 0
movieStudioRows := 0
for _, ranked := range definitions {
definition := ranked.definition
switch {
case strings.HasPrefix(definition.ID, "curated:movies:genre:"):
if movieGenreRows >= 6 {
continue
}
case strings.HasPrefix(definition.ID, "curated:movies:studio:"):
if movieStudioRows >= 3 {
continue
}
}
raws, err := library.CuratedCandidates(
ctx,
definition.ItemTypes,
@@ -618,6 +710,7 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
continue
}
rankedItems := RankCollection(profile, Decode(raws), e.RowSize*2)
rankedItems = diversifyRanked(rankedItems, seed+":"+definition.ID, 5)
items := make([]Item, 0, e.RowSize)
for _, item := range rankedItems {
if _, seen := seenItems[item.ID]; seen {
@@ -640,16 +733,106 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
Kind: definition.Kind,
Items: Raws(items),
})
if strings.HasPrefix(definition.ID, "curated:movies:genre:") {
movieGenreRows++
}
if strings.HasPrefix(definition.ID, "curated:movies:studio:") {
movieStudioRows++
}
}
return rows
}
func (e *Engine) catalogueGenreRows(
ctx context.Context,
source GenreLibrarySource,
definitions []CuratedRow,
) []CuratedRow {
// Ask for extra depth because watched titles and cross-shelf deduplication reduce
// the usable pool. A genre is emitted only if MinRowItems unseen cards survive.
minimumDepth := e.MinRowItems * 2
for _, itemType := range []string{"Series", "Movie"} {
genres, err := source.LibraryGenres(ctx, []string{itemType}, minimumDepth)
if err != nil {
e.log.Warn("library genre discovery failed", "type", itemType, "error", err)
continue
}
for _, genre := range genres {
idPrefix, titleSuffix, kind := "curated:shows:genre:", " Shows", "shows"
if itemType == "Movie" {
idPrefix, titleSuffix, kind = "curated:movies:genre:", " Movies", "movies"
}
id := idPrefix + rowSlug(genre)
replaced := false
for index := range definitions {
existing := &definitions[index]
if existing.Kind == kind && len(existing.Genres) == 1 &&
strings.EqualFold(existing.Genres[0], genre) {
// Catalogue depth proves this can be a useful shelf even when it
// is outside the viewer's established affinity.
existing.RequireAffinity = false
replaced = true
break
}
}
if !replaced {
definitions = append(definitions, CuratedRow{
ID: id, Title: genre + titleSuffix, Kind: kind,
ItemTypes: []string{itemType}, Genres: []string{genre},
})
}
}
}
return definitions
}
func rowSlug(value string) string {
var out strings.Builder
dash := false
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
if dash && out.Len() > 0 {
out.WriteByte('-')
}
out.WriteRune(r)
dash = false
} else {
dash = true
}
}
if out.Len() > 0 {
return out.String()
}
return strconv.FormatUint(stableVariation("", value), 36)
}
func dailySeed(userID string) string {
return userID + ":" + time.Now().UTC().Format("2006-01-02")
}
func stableVariation(seed, value string) uint64 {
hash := fnv.New64a()
_, _ = hash.Write([]byte(seed))
_, _ = hash.Write([]byte{0})
_, _ = hash.Write([]byte(value))
return hash.Sum64()
}
// diversifyRanked preserves relevance bands while changing the exact poster sequence
// per user and day. Affinity/rating still choose each five-card band; variation only
// decides which equally strong-looking option catches the eye first.
func diversifyRanked(items []Item, seed string, bandSize int) []Item {
if bandSize < 2 || len(items) < 2 {
return items
}
out := append([]Item(nil), items...)
for start := 0; start < len(out); start += bandSize {
end := start + bandSize
if end > len(out) {
end = len(out)
}
sort.SliceStable(out[start:end], func(i, j int) bool {
return stableVariation(seed, out[start+i].ID) <
stableVariation(seed, out[start+j].ID)
})
}
return out
}
// gatherSignals reads what the user has watched and favourited, in parallel.
func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) {
var (
@@ -795,6 +978,59 @@ func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile
}, true
}
// RelatedTo answers a detail page: why this viewer might enjoy one title, and what else
// in the library is like it.
//
// Both halves come from the same profile the home rows are built from, so the page can
// never explain a taste the engine does not hold. A failed Similar lookup still returns
// the reasons — the strip under the description is worth more than the carousel.
func (e *Engine) RelatedTo(
ctx context.Context,
cred emby.Credentials,
item Item,
limit int,
) (reasons []string, related []Item, err error) {
history, favorites, err := e.gatherSignals(ctx, cred)
if err != nil {
return nil, nil, err
}
profile := BuildProfile(history, favorites)
reasons = Why(profile, item, ReasonLimit)
if limit <= 0 {
limit = e.RowSize
}
result, similarErr := e.source.Similar(ctx, cred, item.ID, url.Values{
"UserId": {cred.UserID},
"Limit": {strconv.Itoa(limit * 2)},
"Fields": {candidateFields},
"ImageTypeLimit": {"1"},
"EnableImages": {"true"},
"EnableImageTypes": {rowImageTypes},
"EnableUserData": {"true"},
})
if similarErr != nil {
e.log.Warn("related lookup failed", "item", item.ID, "error", similarErr)
return reasons, nil, nil
}
candidates := Decode(result.Items)
related = FilterUnseen(profile, candidates, limit)
// A carousel of two looks broken. Someone deep into a franchise has seen most of
// what resembles it, so fall back to Emby's unfiltered order rather than a stub.
if len(related) < e.MinRowItems && len(candidates) > len(related) {
related = trim(candidates, limit)
}
return reasons, related, nil
}
func trim(items []Item, limit int) []Item {
if limit > 0 && len(items) > limit {
return items[:limit]
}
return items
}
// historyRow is the genre-affinity row: unwatched titles from the genres the user has
// been spending time in, ranked by how closely they match the whole profile.
func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile Profile) (Row, bool) {
+118 -11
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/url"
@@ -36,6 +37,22 @@ type fakeCuratedLibrary struct {
byGenre map[string][]json.RawMessage
}
type fakeGenreLibrary struct {
*fakeCuratedLibrary
genresByType map[string][]string
}
func (f *fakeGenreLibrary) LibraryGenres(
_ context.Context,
itemTypes []string,
_ int,
) ([]string, error) {
if len(itemTypes) == 0 {
return nil, nil
}
return f.genresByType[itemTypes[0]], nil
}
type fakeForYouLibrary struct {
items []json.RawMessage
}
@@ -125,10 +142,12 @@ func (f *fakeSource) NextUp(
return &emby.ItemsResult{Items: f.nextUp}, nil
}
func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T) {
func TestAbandonedShowsRequireAnEmbyNextUpAndStayInTheFirstSeason(t *testing.T) {
now := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
catalogue := Decode([]json.RawMessage{
json.RawMessage(`{"Id":"early","Name":"Early Show","Type":"Series"}`),
json.RawMessage(`{"Id":"finished-first","Name":"Finished First","Type":"Series"}`),
json.RawMessage(`{"Id":"unknown-season","Name":"Unknown Season","Type":"Series"}`),
json.RawMessage(`{"Id":"deep","Name":"Deep Show","Type":"Series"}`),
json.RawMessage(`{"Id":"complete","Name":"Complete Show","Type":"Series"}`),
json.RawMessage(`{"Id":"recent","Name":"Recent Show","Type":"Series"}`),
@@ -143,12 +162,18 @@ func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T)
}
sessions := []tracearr.Session{
session("Early Show", 1, 2, 40),
session("Finished First", 1, 10, 50),
session("Deep Show", 2, 8, 60),
session("Complete Show", 4, 10, 50),
session("Recent Show", 1, 3, 5),
}
unknown := session("Unknown Season", 1, 2, 30)
unknown.SeasonNumber = nil
sessions = append(sessions, unknown)
nextUp := Decode([]json.RawMessage{
json.RawMessage(`{"Id":"early-next","Type":"Episode","SeriesId":"early","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
json.RawMessage(`{"Id":"finished-next","Type":"Episode","SeriesId":"finished-first","ParentIndexNumber":2,"IndexNumber":1,"RunTimeTicks":18000000000}`),
json.RawMessage(`{"Id":"unknown-next","Type":"Episode","SeriesId":"unknown-season","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
json.RawMessage(`{"Id":"deep-next","Type":"Episode","SeriesId":"deep","ParentIndexNumber":3,"IndexNumber":1,"RunTimeTicks":36000000000}`),
json.RawMessage(`{"Id":"recent-next","Type":"Episode","SeriesId":"recent","ParentIndexNumber":1,"IndexNumber":4}`),
})
@@ -160,20 +185,21 @@ func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T)
compatibilityProfile{directCodecs: map[string]int{}, transcodeCodecs: map[string]int{}},
now,
)
if len(got) != 2 {
t.Fatalf("pickup candidates = %+v, want only two abandoned unfinished shows", got)
if len(got) != 3 {
t.Fatalf("pickup candidates = %+v, want three early-show abandonments", got)
}
if got[0].ItemID != "deep" || got[1].ItemID != "early" {
t.Fatalf("pickup order = %q, %q; later-season commitment should lead", got[0].ItemID, got[1].ItemID)
byID := map[string]PreparedCandidate{}
for _, candidate := range got {
byID[candidate.ItemID] = candidate
}
if got[0].RecommendationReason != "You made it through season 2 · season 3 is waiting" {
t.Fatalf("later-season reason = %q", got[0].RecommendationReason)
if byID["early"].RecommendationReason != "You left this in season 1 · pick it up again" {
t.Fatalf("season-one reason = %q", byID["early"].RecommendationReason)
}
if got[1].RecommendationReason != "You left this in season 1 · pick it up again" {
t.Fatalf("season-one reason = %q", got[1].RecommendationReason)
if byID["finished-first"].RecommendationReason != "You finished season 1 · season 2 is waiting" {
t.Fatalf("season-two waiting reason = %q", byID["finished-first"].RecommendationReason)
}
if got[0].RuntimeMinutes != 60 || got[1].RuntimeMinutes != 30 {
t.Fatalf("next-episode runtimes = %d, %d", got[0].RuntimeMinutes, got[1].RuntimeMinutes)
if byID["unknown-season"].RecommendationReason != "You left this in season 1 · pick it up again" {
t.Fatalf("inferred first-season reason = %q", byID["unknown-season"].RecommendationReason)
}
}
@@ -509,6 +535,28 @@ func TestRecommendationSessionsDiscardPrerolls(t *testing.T) {
}
}
func TestEpisodeEvidenceUsesParentSeriesMetadata(t *testing.T) {
seriesRaw, _ := json.Marshal(map[string]any{
"Id": "series-1", "Name": "The Show", "Type": "Series",
"People": []map[string]string{{"Name": "Lead Actor", "Type": "Actor"}},
"Studios": []map[string]string{{"Name": "Great Studio"}},
"Genres": []string{"Drama"},
})
episodeRaw, _ := json.Marshal(map[string]any{
"Id": "episode-1", "Name": "Pilot", "Type": "Episode",
"SeriesId": "series-1", "SeriesName": "The Show",
})
series := Decode([]json.RawMessage{seriesRaw})[0]
episode := Decode([]json.RawMessage{episodeRaw})[0]
got := newCatalogueIndex([]Item{series}).evidenceItem(episode)
if got.ID != "series-1" || len(got.People) != 1 ||
len(got.Studios) != 1 || len(got.Genres) != 1 {
t.Fatalf("episode evidence was not enriched from its series: %+v", got)
}
}
func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
source := &fakeSource{
itemsByFilter: map[string][]json.RawMessage{
@@ -731,6 +779,65 @@ func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
}
}
func TestCatalogueGenresExpandMovieAndShowShelves(t *testing.T) {
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
engine := testEngine(source)
engine.Library = &fakeGenreLibrary{
fakeCuratedLibrary: &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
"Western": {
raw("western-1", "Western One", "Movie", "Western"),
raw("western-2", "Western Two", "Movie", "Western"),
},
"Reality": {
raw("reality-1", "Reality One", "Series", "Reality"),
raw("reality-2", "Reality Two", "Series", "Reality"),
},
}},
genresByType: map[string][]string{
"Movie": {"Western"},
"Series": {"Reality"},
},
}
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"})
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
for _, row := range rows {
got[row.ID] = true
}
if !got["curated:movies:genre:western"] || !got["curated:shows:genre:reality"] {
t.Fatalf("catalogue-backed genre rows missing: %v", rowTitles(rows))
}
}
func TestDiversifyRankedIsStableAndOnlyReordersWithinBands(t *testing.T) {
items := make([]Item, 0, 12)
for i := 0; i < 12; i++ {
items = append(items, Item{ID: strconv.Itoa(i)})
}
first := diversifyRanked(items, "user:day", 5)
second := diversifyRanked(items, "user:day", 5)
if fmt.Sprint(first) != fmt.Sprint(second) {
t.Fatal("same user/day seed must produce stable poster ordering")
}
for index, item := range first {
if index/5 != mustAtoi(t, item.ID)/5 {
t.Fatalf("item %s escaped its relevance band: %+v", item.ID, first)
}
}
}
func mustAtoi(t *testing.T, value string) int {
t.Helper()
parsed, err := strconv.Atoi(value)
if err != nil {
t.Fatal(err)
}
return parsed
}
// A failing similarity lookup is one dead row, not a dead home screen.
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
source := &fakeSource{
+124
View File
@@ -0,0 +1,124 @@
package recommend
import (
"sort"
"strconv"
"strings"
"time"
)
// Why a title suits one viewer, in that viewer's own words.
//
// This is deliberately separate from Score. The scorer decides *order* and is allowed to
// be opaque; this decides *wording* and must never claim an affinity the profile did not
// actually learn — every reason below is read straight out of the weights built from real
// history, so a viewer who has watched nothing gets the honest, taste-free ones.
// ReasonLimit is what fits on one line of a detail page without wrapping. Beyond three
// the strip stops reading as an explanation and starts reading as marketing.
const ReasonLimit = 3
// reasonFloor is the weight below which an affinity is a coincidence rather than a
// habit — one stray episode should not put a genre on the screen as a reason.
const reasonFloor = 0.35
// Why returns up to limit short phrases explaining the item to this viewer, strongest
// first. Never nil: a profile with nothing in it still yields the catalogue facts.
func Why(profile Profile, item Item, limit int) []string {
if limit <= 0 {
limit = ReasonLimit
}
reasons := make([]string, 0, limit)
add := func(reason string) {
if len(reasons) < limit && reason != "" {
reasons = append(reasons, reason)
}
}
if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor {
add("Because you watch " + genre)
}
if name, weight := heaviestPerson(profile, item); weight >= reasonFloor {
add("You've watched " + name + " before")
}
if studio, weight := heaviest(profile.StudioWeights, studioNames(item)); weight >= reasonFloor {
add("More from " + studio)
}
// Catalogue facts, used to fill the strip out. They are true for everyone, which is
// exactly why they come last: they explain the title, not the viewer.
// Kept short deliberately. Three chips share one line on a 960dp TV, and this is the
// one that most often lands third, where a long phrase is the one that gets clipped.
if item.CommunityRating >= 7.5 {
add("Well rated (" + strconv.FormatFloat(round1(item.CommunityRating), 'f', 1, 64) + ")")
}
if item.ProductionYear > 0 && time.Now().Year()-item.ProductionYear <= 1 {
add("A recent release")
}
if len(reasons) == 0 && len(item.Genres) > 0 {
add(strings.TrimSpace(item.Genres[0]) + " from your library")
}
return reasons
}
// heaviest picks the wanted key with the most weight behind it, matched case-insensitively
// because Emby's own tagging is not consistent about it. Ties break alphabetically so the
// same profile and item always produce the same sentence.
func heaviest(weights map[string]float64, wanted []string) (string, float64) {
best, bestWeight := "", 0.0
for _, candidate := range wanted {
candidate = strings.TrimSpace(candidate)
if candidate == "" {
continue
}
weight := weightFold(weights, candidate)
if weight <= 0 {
continue
}
if weight > bestWeight || (weight == bestWeight && candidate < best) {
best, bestWeight = candidate, weight
}
}
return best, bestWeight
}
func heaviestPerson(profile Profile, item Item) (string, float64) {
names := make([]string, 0, len(item.People))
for _, person := range item.People {
if isExplainablePerson(person.Type) {
names = append(names, person.Name)
}
}
return heaviest(profile.PersonWeights, names)
}
func round1(value float64) float64 {
return float64(int(value*10+0.5)) / 10
}
// TopPeople is the explanation layer's view of the cast a viewer follows, heaviest first.
// Exported for the admin page, which shows what the engine believes about a household.
func (p Profile) TopPeople(n int) []string {
type kv struct {
name string
weight float64
}
pairs := make([]kv, 0, len(p.PersonWeights))
for name, weight := range p.PersonWeights {
pairs = append(pairs, kv{name, weight})
}
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].weight != pairs[j].weight {
return pairs[i].weight > pairs[j].weight
}
return pairs[i].name < pairs[j].name
})
if n > len(pairs) {
n = len(pairs)
}
out := make([]string, 0, n)
for _, pair := range pairs[:n] {
out = append(out, pair.name)
}
return out
}
+136
View File
@@ -0,0 +1,136 @@
package recommend
import (
"encoding/json"
"strings"
"testing"
)
func explainItem(t *testing.T, payload string) Item {
t.Helper()
items := Decode([]json.RawMessage{json.RawMessage(payload)})
if len(items) != 1 {
t.Fatalf("payload did not decode to one item: %s", payload)
}
return items[0]
}
func explainProfile(t *testing.T, history ...string) Profile {
t.Helper()
items := make([]Item, 0, len(history))
for _, payload := range history {
items = append(items, explainItem(t, payload))
}
return BuildProfile(items, nil)
}
const thrillerHistory = `{
"Id":"h1","Name":"Sicario","Type":"Movie","Genres":["Thriller","Crime"],
"Studios":[{"Name":"Lionsgate"}],
"People":[{"Name":"Denis Villeneuve","Type":"Director"},{"Name":"Emily Blunt","Type":"Actor"}]
}`
func TestWhyNamesTheGenreTheViewerActuallyWatches(t *testing.T) {
profile := explainProfile(t, thrillerHistory)
candidate := explainItem(t, `{
"Id":"c1","Name":"Prisoners","Type":"Movie","Genres":["Thriller"],
"People":[{"Name":"Denis Villeneuve","Type":"Director"}]
}`)
reasons := Why(profile, candidate, ReasonLimit)
if len(reasons) == 0 || reasons[0] != "Because you watch Thriller" {
t.Fatalf("expected the genre reason first, got %v", reasons)
}
if !strings.Contains(strings.Join(reasons, "|"), "Denis Villeneuve") {
t.Fatalf("expected the shared director to be named, got %v", reasons)
}
}
func TestWhyNeverClaimsAnAffinityThatIsNotThere(t *testing.T) {
profile := explainProfile(t, thrillerHistory)
candidate := explainItem(t, `{
"Id":"c2","Name":"Paddington","Type":"Movie","Genres":["Family"],
"CommunityRating":8.2,
"People":[{"Name":"Paul King","Type":"Director"}]
}`)
reasons := Why(profile, candidate, ReasonLimit)
for _, reason := range reasons {
if strings.HasPrefix(reason, "Because you watch") ||
strings.HasPrefix(reason, "You've watched") {
t.Fatalf("invented a taste the profile does not hold: %v", reasons)
}
}
if len(reasons) == 0 || reasons[0] != "Well rated (8.2)" {
t.Fatalf("expected the catalogue fact to carry the strip, got %v", reasons)
}
}
func TestWhyAlwaysSaysSomething(t *testing.T) {
candidate := explainItem(t, `{"Id":"c3","Name":"Unknown","Type":"Movie","Genres":["Drama"]}`)
reasons := Why(Profile{}, candidate, ReasonLimit)
if len(reasons) != 1 || reasons[0] != "Drama from your library" {
t.Fatalf("an empty profile should still explain the title, got %v", reasons)
}
}
func TestWhyIsCappedAndOrdered(t *testing.T) {
// A second, Thriller-only title so Thriller genuinely outweighs Crime — with one
// history item they tie and the alphabetical tie-break would decide it.
profile := explainProfile(t, thrillerHistory, `{
"Id":"h2","Name":"Nightcrawler","Type":"Movie","Genres":["Thriller"],
"Studios":[{"Name":"Lionsgate"}],
"People":[{"Name":"Emily Blunt","Type":"Actor"}]
}`)
candidate := explainItem(t, `{
"Id":"c4","Name":"Wind River","Type":"Movie","Genres":["Thriller","Crime"],
"Studios":[{"Name":"Lionsgate"}],"CommunityRating":9.1,"ProductionYear":2017,
"People":[{"Name":"Emily Blunt","Type":"Actor"}]
}`)
reasons := Why(profile, candidate, ReasonLimit)
if len(reasons) != ReasonLimit {
t.Fatalf("expected exactly %d reasons, got %v", ReasonLimit, reasons)
}
want := []string{
"Because you watch Thriller",
"You've watched Emily Blunt before",
"More from Lionsgate",
}
for i, reason := range want {
if reasons[i] != reason {
t.Fatalf("reason %d: want %q, got %q (%v)", i, reason, reasons[i], reasons)
}
}
}
func TestWhyIsStableAcrossCalls(t *testing.T) {
profile := explainProfile(t, thrillerHistory)
candidate := explainItem(t, `{
"Id":"c5","Name":"Hell or High Water","Type":"Movie","Genres":["Crime","Thriller"]
}`)
first := Why(profile, candidate, ReasonLimit)
for i := 0; i < 20; i++ {
if got := Why(profile, candidate, ReasonLimit); !equalStrings(first, got) {
t.Fatalf("reasons changed between calls: %v then %v", first, got)
}
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+80 -8
View File
@@ -49,7 +49,9 @@ type PreparedProfile struct {
GenreAffinity map[string]float64
TitleAffinity map[string]PreparedTitleAffinity
StudioAffinity map[string]float64
ContextAffinity ContextAffinityProfile
CodecOutcomes map[string]map[string]int
Weighted WeightedProfile
SignalsThrough *time.Time
}
@@ -77,6 +79,8 @@ type PreparedResult struct {
var ErrPreparedLibraryUnavailable = errors.New("recommend: prepared library unavailable")
const maxPreparedCandidatePool = 750
// PrepareForYou performs the expensive work outside a television request. It consumes
// locally imported Tracearr sessions and the complete imported catalogue, producing a
// compact profile and an intentionally over-provisioned ranked pool.
@@ -167,6 +171,26 @@ func (e *Engine) PrepareForYou(
tracearrUserID := ""
tracearrUsername := strings.TrimSpace(username)
titleSignalCount := map[string]int{}
contextAffinity := NewContextAffinityProfile()
weightedEvidence := make([]ViewingEvidence, 0, len(history)+len(favorites)+len(sessions))
for _, item := range history {
item = index.evidenceItem(item)
completion := 0.0
if item.UserData.Played {
completion = 1
} else if item.RunTimeTicks > 0 {
completion = float64(item.UserData.PlaybackPositionTicks) / float64(item.RunTimeTicks)
}
weightedEvidence = append(weightedEvidence, ViewingEvidence{
Item: item, Completion: completion, Repeat: maxInt(1, item.UserData.PlayCount),
})
}
for _, item := range favorites {
item = index.evidenceItem(item)
weightedEvidence = append(weightedEvidence, ViewingEvidence{
Item: item, Completion: 0, Favorite: true,
})
}
for i, session := range sessions {
completion := session.Completion()
@@ -225,6 +249,18 @@ func (e *Engine) PrepareForYou(
current.SessionID = session.ID
}
titleAffinity[item.ID] = current
if started, ok := parseTracearrTime(session.StartedAt); ok {
contextAffinity.Add(item, started, completion, i, e.Location)
weightedEvidence = append(weightedEvidence, ViewingEvidence{
Item: item, Completion: completion, Repeat: repeats + 1,
OccurredAt: started, SessionMinutes: int(int64(session.DurationMs) / 60_000),
})
} else {
weightedEvidence = append(weightedEvidence, ViewingEvidence{
Item: item, Completion: completion, Repeat: repeats + 1,
SessionMinutes: int(int64(session.DurationMs) / 60_000),
})
}
if completion >= 0.9 {
addCompletedEvidence(item, session.ID)
@@ -262,9 +298,12 @@ func (e *Engine) PrepareForYou(
return ranked[i].item.Name < ranked[j].item.Name
})
prepared := make([]PreparedCandidate, 0, len(ranked))
prepared := make([]PreparedCandidate, 0, min(len(ranked), maxPreparedCandidatePool))
completedReasonCounts := map[string]int{}
for rank, entry := range ranked {
if len(prepared) == maxPreparedCandidatePool {
break
}
reason, label, kind, genre, evidence := explainPreparedRecommendation(
profile, entry.item, compatibility, browsed[entry.item.ID], evidenceByGenre,
completedReasonCounts,
@@ -294,6 +333,9 @@ func (e *Engine) PrepareForYou(
pickups[i].BaseRank = i + 1
}
prepared = append(pickups, prepared...)
if len(prepared) > maxPreparedCandidatePool {
prepared = prepared[:maxPreparedCandidatePool]
}
}
meanCompletion := 0.0
@@ -310,7 +352,11 @@ func (e *Engine) PrepareForYou(
SourceSessionCount: len(sessions), MeanCompletionRatio: meanCompletion,
TypicalSessionMinutes: medianInt(durations),
GenreAffinity: profile.GenreWeights, TitleAffinity: titleAffinity,
StudioAffinity: profile.StudioWeights, CodecOutcomes: codecs,
StudioAffinity: profile.StudioWeights, ContextAffinity: contextAffinity,
CodecOutcomes: codecs,
Weighted: BuildWeightedProfileWithConfig(
weightedEvidence, time.Now(), e.Location, e.WeightedConfig,
),
SignalsThrough: signalsThrough,
},
Candidates: prepared,
@@ -390,9 +436,11 @@ func abandonedShowCandidates(
}
if activity.After(current.lastActivity) {
current.lastActivity = activity
if session.SeasonNumber != nil {
current.lastSeason = *session.SeasonNumber
}
}
// Track the furthest season ever reached, not merely the season from the most
// recent replay. Any season-two evidence disqualifies a first-season pickup.
if session.SeasonNumber != nil && *session.SeasonNumber > current.lastSeason {
current.lastSeason = *session.SeasonNumber
}
if session.Completion() >= 0.9 && session.SeasonNumber != nil &&
session.EpisodeNumber != nil {
@@ -420,7 +468,14 @@ func abandonedShowCandidates(
cutoff := now.Add(-abandonedShowAge)
for seriesID, watched := range progress {
next, unfinished := nextBySeries[seriesID]
if !unfinished || watched.lastActivity.After(cutoff) {
// This shelf is intentionally about promising shows abandoned in or just after
// their first season. Later-season lapses are ordinary Next Up material. Some
// Tracearr episode records lack a season number, so a season-one Next Up is also
// sufficient evidence that the viewer is still at the beginning.
firstSeasonAbandonment := watched.lastSeason == 1 &&
next.ParentIndexNumber <= 2 ||
watched.lastSeason == 0 && next.ParentIndexNumber == 1
if !unfinished || watched.lastActivity.After(cutoff) || !firstSeasonAbandonment {
continue
}
eligible = append(eligible, pickup{progress: watched, next: next})
@@ -478,7 +533,7 @@ func abandonedShowReason(lastSeason, nextSeason int) string {
return fmt.Sprintf("You made it through season %d · season %d is waiting", lastSeason, nextSeason)
case lastSeason > 1:
return fmt.Sprintf("You made it to season %d · pick it up again", lastSeason)
case lastSeason == 1:
case lastSeason == 1 || lastSeason == 0 && nextSeason == 1:
return "You left this in season 1 · pick it up again"
default:
return "You left this unfinished · pick it up again"
@@ -596,15 +651,17 @@ type catalogueIndex struct {
movieExact map[string]Item
movieLoose map[string]Item
series map[string]Item
byID map[string]Item
ambiguous map[string]bool
}
func newCatalogueIndex(items []Item) catalogueIndex {
index := catalogueIndex{
movieExact: map[string]Item{}, movieLoose: map[string]Item{},
series: map[string]Item{}, ambiguous: map[string]bool{},
series: map[string]Item{}, byID: map[string]Item{}, ambiguous: map[string]bool{},
}
for _, item := range items {
index.byID[item.ID] = item
key := normalizePreparedTitle(item.Name)
switch item.Type {
case "Movie":
@@ -627,6 +684,21 @@ func newCatalogueIndex(items []Item) catalogueIndex {
return index
}
// evidenceItem promotes episode evidence to its series metadata. Emby episode rows often
// omit People and studio details even when those fields were requested, while the parent
// Series record contains the canonical cast and production metadata.
func (i catalogueIndex) evidenceItem(item Item) Item {
if !strings.EqualFold(item.Type, "Episode") || item.SeriesID == "" {
return item
}
parent, ok := i.byID[item.SeriesID]
if !ok {
return item
}
parent.UserData = item.UserData
return parent
}
func (i catalogueIndex) match(session tracearr.Session) (Item, bool) {
if strings.EqualFold(session.MediaType, "episode") && strings.TrimSpace(session.ShowTitle) != "" {
key := normalizePreparedTitle(session.ShowTitle)
+46 -1
View File
@@ -12,6 +12,7 @@ import (
"sort"
"strconv"
"strings"
"time"
"unicode"
)
@@ -37,6 +38,10 @@ type Item struct {
Genres []string `json:"Genres"`
CommunityRating float64 `json:"CommunityRating"`
RunTimeTicks int64 `json:"RunTimeTicks"`
OfficialRating string `json:"OfficialRating"`
CollectionName string `json:"CollectionName"`
PremiereDate string `json:"PremiereDate"`
DateCreated string `json:"DateCreated"`
IndexNumber int `json:"IndexNumber"`
ParentIndexNumber int `json:"ParentIndexNumber"`
Container string `json:"Container"`
@@ -47,6 +52,7 @@ type Item struct {
Studios []struct {
Name string `json:"Name"`
} `json:"Studios"`
People []Person `json:"People"`
UserData struct {
Played bool `json:"Played"`
PlayCount int `json:"PlayCount"`
@@ -99,6 +105,10 @@ type Seed struct {
type Profile struct {
GenreWeights map[string]float64
StudioWeights map[string]float64
// PersonWeights is read only by the explanation layer, never by Score. Casting is a
// good reason to *tell* someone about a title and a poor reason to rank by it: two
// films sharing an actor are often nothing alike.
PersonWeights map[string]float64
// Seen holds item ids *and* series ids already watched or in progress, so a
// recommendation never suggests something the user is already partway through.
Seen map[string]bool
@@ -130,6 +140,7 @@ func BuildProfile(history, favorites []Item) Profile {
profile := Profile{
GenreWeights: map[string]float64{},
StudioWeights: map[string]float64{},
PersonWeights: map[string]float64{},
Seen: map[string]bool{},
SeenTitles: map[string]bool{},
}
@@ -201,6 +212,27 @@ func (p *Profile) absorbTaste(item Item, weight float64) {
p.StudioWeights[s] += weight * 0.4
}
}
for _, person := range item.People {
if !isExplainablePerson(person.Type) {
continue
}
if name := strings.TrimSpace(person.Name); name != "" {
if p.PersonWeights == nil {
p.PersonWeights = map[string]float64{}
}
p.PersonWeights[name] += weight
}
}
}
// isExplainablePerson keeps the cast list down to the roles a viewer would recognise as
// a reason. A gaffer in common is not why anyone picks a film.
func isExplainablePerson(role string) bool {
switch strings.ToLower(strings.TrimSpace(role)) {
case "actor", "director", "writer":
return true
}
return false
}
// TopGenres returns the n heaviest genres, highest first. Ties break alphabetically so
@@ -263,7 +295,20 @@ func (p Profile) Score(candidate Item) float64 {
// A mild quality nudge, capped so a beloved genre still beats a well-rated stranger.
ratingScore := candidate.CommunityRating / 10 * 0.5
return genreScore + studioScore + ratingScore
// New catalogue arrivals should surface without overpowering established taste.
// Production year is consistently available from both the imported library and
// Emby, unlike DateCreated on deliberately narrow recommendation payloads.
var freshnessScore float64
age := time.Now().Year() - candidate.ProductionYear
switch {
case candidate.ProductionYear <= 0:
case age <= 1:
freshnessScore = 0.25
case age <= 3:
freshnessScore = 0.12
}
return genreScore + studioScore + ratingScore + freshnessScore
}
// CollectionAffinity decides which curated shelf appears first for this user.
+722
View File
@@ -0,0 +1,722 @@
package recommend
import (
"encoding/json"
"hash/fnv"
"math"
"sort"
"strings"
"time"
)
// WeightedConfig is intentionally data, not code: operators can tune the algorithm
// without changing its shape or retraining an opaque model.
type WeightedConfig struct {
MinimumEvidence int
ExplorationRate float64
MaxPrimaryGenre int
MaxLeadPerson int
NewReleaseDays int
ImpressionFloor int
ImpressionPenalty float64
IgnoredPenalty float64
CompletionWeight float64
AbandonmentWeight float64
RecencyHalfLifeDays float64
CommunityPriorWeight float64
HouseholdPriorWeight float64
CompatibilityWeight float64
ContextWeight float64
RuntimeContextWeight float64
ExplicitPositiveBoost float64
ExplicitNegativeScore float64
}
func DefaultWeightedConfig() WeightedConfig {
return WeightedConfig{
MinimumEvidence: 2, ExplorationRate: 0.08,
MaxPrimaryGenre: 3, MaxLeadPerson: 2, NewReleaseDays: 180,
ImpressionFloor: 3, ImpressionPenalty: 0.12, IgnoredPenalty: 0.28,
CompletionWeight: 1, AbandonmentWeight: 0.55, RecencyHalfLifeDays: 45,
CommunityPriorWeight: 0.35, HouseholdPriorWeight: 0.45,
CompatibilityWeight: 0.7, ContextWeight: 0.8, RuntimeContextWeight: 0.65,
ExplicitPositiveBoost: 3.5, ExplicitNegativeScore: -1_000,
}
}
type Person struct {
Name string `json:"Name"`
Type string `json:"Type"`
Role string `json:"Role"`
}
// Affinity retains its evidence count so a single accidental play cannot silently
// become a durable preference.
type Affinity struct {
Weight float64 `json:"weight"`
Evidence int `json:"evidence"`
}
type WeightedProfile struct {
Genres map[string]Affinity `json:"genres,omitempty"`
Studios map[string]Affinity `json:"studios,omitempty"`
Actors map[string]Affinity `json:"actors,omitempty"`
Directors map[string]Affinity `json:"directors,omitempty"`
Franchises map[string]Affinity `json:"franchises,omitempty"`
RuntimeRanges map[string]Affinity `json:"runtimeRanges,omitempty"`
AgeRatings map[string]Affinity `json:"ageRatings,omitempty"`
CommunityRatings map[string]Affinity `json:"communityRatings,omitempty"`
ReleasePeriods map[string]Affinity `json:"releasePeriods,omitempty"`
ContentTypes map[string]Affinity `json:"contentTypes,omitempty"`
Seen map[string]bool `json:"seen,omitempty"`
ExplicitPositive map[string]bool `json:"explicitPositive,omitempty"`
ExplicitNegative map[string]bool `json:"explicitNegative,omitempty"`
TypicalSessionMins map[string]float64 `json:"typicalSessionMinutes,omitempty"`
SessionEvidence map[string]int `json:"sessionEvidence,omitempty"`
SourceEvents int `json:"sourceEvents"`
}
type ViewingEvidence struct {
Item Item
Completion float64
Repeat int
OccurredAt time.Time
SessionMinutes int
Favorite bool
}
type OnboardingPreferences struct {
Completed bool `json:"completed"`
Ratings map[string]int `json:"ratings,omitempty"`
Genres []string `json:"genres,omitempty"`
Studios []string `json:"studios,omitempty"`
Actors []string `json:"actors,omitempty"`
Directors []string `json:"directors,omitempty"`
ContentTypes []string `json:"contentTypes,omitempty"`
}
func (p *WeightedProfile) ApplyOnboarding(preferences OnboardingPreferences, minimumEvidence int) {
p.ensureAffinityMaps()
if minimumEvidence < 1 {
minimumEvidence = 1
}
add := func(target map[string]Affinity, values []string) {
for _, key := range values {
key = normalizeDimension(key)
if key != "" {
target[key] = Affinity{Weight: 0.8, Evidence: minimumEvidence}
}
}
}
add(p.Genres, preferences.Genres)
add(p.Studios, preferences.Studios)
add(p.Actors, preferences.Actors)
add(p.Directors, preferences.Directors)
add(p.ContentTypes, preferences.ContentTypes)
}
// ApplyOnboardingRating turns a deliberate 15 title rating into immediate profile
// evidence. Unlike an accidental play, an explicit rating is trusted enough to satisfy
// MinimumEvidence on its own.
func (p *WeightedProfile) ApplyOnboardingRating(item Item, rating, minimumEvidence int) {
p.ensureAffinityMaps()
if p.Seen == nil {
p.Seen = map[string]bool{}
}
if item.ID != "" {
p.Seen[item.ID] = true
}
if item.SeriesID != "" {
p.Seen[item.SeriesID] = true
}
if rating < 1 || rating > 5 || rating == 3 {
return
}
if minimumEvidence < 1 {
minimumEvidence = 1
}
// Two stars either side of neutral maps to a strong but bounded ±2.4 signal.
total := float64(rating-3) * 1.2
for range minimumEvidence {
addItemAffinities(p, item, total/float64(minimumEvidence))
}
}
// BuildWeightedProfile treats Tracearr/Emby events as evidence. Completion, repetition
// and recency affect strength, while Affinity.Evidence enforces the repeated-pattern
// threshold at scoring time.
func BuildWeightedProfile(events []ViewingEvidence, now time.Time, location *time.Location) WeightedProfile {
return BuildWeightedProfileWithConfig(events, now, location, DefaultWeightedConfig())
}
func BuildWeightedProfileWithConfig(
events []ViewingEvidence,
now time.Time,
location *time.Location,
cfg WeightedConfig,
) WeightedProfile {
if cfg.MinimumEvidence < 1 {
cfg = DefaultWeightedConfig()
}
p := WeightedProfile{
Genres: map[string]Affinity{}, Studios: map[string]Affinity{},
Actors: map[string]Affinity{}, Directors: map[string]Affinity{},
Franchises: map[string]Affinity{}, RuntimeRanges: map[string]Affinity{},
AgeRatings: map[string]Affinity{}, CommunityRatings: map[string]Affinity{},
ReleasePeriods: map[string]Affinity{},
ContentTypes: map[string]Affinity{}, Seen: map[string]bool{},
ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{},
TypicalSessionMins: map[string]float64{}, SessionEvidence: map[string]int{},
}
if location == nil {
location = time.Local
}
sessionTotals := map[string]float64{}
for _, event := range events {
if event.Item.ID == "" {
continue
}
p.SourceEvents++
p.Seen[event.Item.ID] = event.Completion > 0
if event.Item.SeriesID != "" && event.Completion > 0 {
p.Seen[event.Item.SeriesID] = true
}
completion := clamp01(event.Completion)
strength := evidenceStrength(completion, cfg)
if !event.OccurredAt.IsZero() {
ageDays := math.Max(0, now.Sub(event.OccurredAt).Hours()/24)
strength *= math.Pow(0.5, ageDays/math.Max(1, cfg.RecencyHalfLifeDays))
}
if event.Repeat > 1 {
strength *= 1 + math.Min(1.2, math.Log2(float64(event.Repeat))*0.45)
}
if event.Favorite {
strength += 0.8
p.ExplicitPositive[event.Item.ID] = true
}
addItemAffinities(&p, event.Item, strength)
if event.SessionMinutes > 0 && !event.OccurredAt.IsZero() {
slot := contextSlotKey(event.OccurredAt.In(location).Weekday(), dayPart(event.OccurredAt.In(location).Hour()))
sessionTotals[slot] += float64(event.SessionMinutes)
p.SessionEvidence[slot]++
}
}
for slot, total := range sessionTotals {
p.TypicalSessionMins[slot] = total / float64(p.SessionEvidence[slot])
}
return p
}
func evidenceStrength(completion float64, cfg WeightedConfig) float64 {
switch {
case completion >= 0.9:
return cfg.CompletionWeight
case completion >= 0.5:
return cfg.CompletionWeight * 0.45
case completion >= 0.15:
return -cfg.AbandonmentWeight
default:
// A very brief start is weak evidence, not a strong dislike.
return -0.08
}
}
func addItemAffinities(p *WeightedProfile, item Item, weight float64) {
for _, value := range item.Genres {
addAffinity(p.Genres, value, weight)
}
for _, value := range item.Studios {
addAffinity(p.Studios, value.Name, weight)
}
for _, person := range item.People {
switch strings.ToLower(strings.TrimSpace(person.Type)) {
case "actor":
addAffinity(p.Actors, person.Name, weight*0.65)
case "director":
addAffinity(p.Directors, person.Name, weight*0.8)
}
}
addAffinity(p.Franchises, item.Franchise(), weight*0.85)
addAffinity(p.RuntimeRanges, runtimeRange(item.RuntimeMinutes()), weight*0.55)
addAffinity(p.AgeRatings, item.OfficialRating, weight*0.45)
addAffinity(p.CommunityRatings, communityRatingRange(item.CommunityRating), weight*0.35)
addAffinity(p.ReleasePeriods, releasePeriod(item.ProductionYear), weight*0.5)
addAffinity(p.ContentTypes, item.Type, weight*0.6)
}
// ApplyExplicitPreference lets More Like This / Not for Me influence adjacent titles.
// The item itself is always boosted/excluded; metadata still needs repeated negative
// actions before it becomes a broader dislike because normal minimum-evidence rules
// remain in force.
func (p *WeightedProfile) ApplyExplicitPreference(item Item, positive bool) {
p.ensureAffinityMaps()
if p.ExplicitPositive == nil {
p.ExplicitPositive = map[string]bool{}
}
if p.ExplicitNegative == nil {
p.ExplicitNegative = map[string]bool{}
}
if positive {
p.ExplicitPositive[item.ID] = true
addItemAffinities(p, item, 1.5)
return
}
p.ExplicitNegative[item.ID] = true
addItemAffinities(p, item, -1.2)
}
func (p *WeightedProfile) ensureAffinityMaps() {
if p.Genres == nil {
p.Genres = map[string]Affinity{}
}
if p.Studios == nil {
p.Studios = map[string]Affinity{}
}
if p.Actors == nil {
p.Actors = map[string]Affinity{}
}
if p.Directors == nil {
p.Directors = map[string]Affinity{}
}
if p.Franchises == nil {
p.Franchises = map[string]Affinity{}
}
if p.RuntimeRanges == nil {
p.RuntimeRanges = map[string]Affinity{}
}
if p.AgeRatings == nil {
p.AgeRatings = map[string]Affinity{}
}
if p.CommunityRatings == nil {
p.CommunityRatings = map[string]Affinity{}
}
if p.ReleasePeriods == nil {
p.ReleasePeriods = map[string]Affinity{}
}
if p.ContentTypes == nil {
p.ContentTypes = map[string]Affinity{}
}
}
func addAffinity(values map[string]Affinity, key string, weight float64) {
key = normalizeDimension(key)
if key == "" {
return
}
value := values[key]
value.Weight += weight
value.Evidence++
values[key] = value
}
type ItemExposure struct {
Impressions int `json:"impressions"`
Focuses int `json:"focuses"`
Selects int `json:"selects"`
LastShown time.Time `json:"lastShown,omitempty"`
}
type RankIntent struct {
ID string
ItemTypes []string
UnseenOnly bool
NewReleasesOnly bool
MaxRuntimeMins int
PreferShort bool
HiddenLibrary bool
SearchRelevance map[string]float64
HouseholdScores map[string]float64
Compatibility map[string]float64
Now time.Time
Location *time.Location
}
type ScoreExplanation struct {
Total float64 `json:"total"`
Components map[string]float64 `json:"components"`
Reasons []string `json:"reasonCodes"`
Exploration bool `json:"exploration,omitempty"`
}
type RankedItem struct {
Item Item
Explanation ScoreExplanation
}
// WeightedRank applies the same scoring foundation to any page or row. RankIntent only
// changes eligibility and emphasis; it never creates a separate recommendation model.
func WeightedRank(
profile WeightedProfile,
candidates []Item,
exposures map[string]ItemExposure,
intent RankIntent,
cfg WeightedConfig,
limit int,
) []RankedItem {
if cfg.MinimumEvidence < 1 {
cfg = DefaultWeightedConfig()
}
now := intent.Now
if now.IsZero() {
now = time.Now()
}
type scored struct {
item Item
exp ScoreExplanation
}
values := make([]scored, 0, len(candidates))
seenIDs := map[string]bool{}
for _, item := range candidates {
if item.ID == "" || seenIDs[item.ID] || !eligibleForIntent(profile, item, intent, cfg, now) {
continue
}
seenIDs[item.ID] = true
exp := scoreWeightedItem(profile, item, exposures[item.ID], intent, cfg, now)
if exp.Total <= cfg.ExplicitNegativeScore/2 {
continue
}
values = append(values, scored{item: item, exp: exp})
}
sort.SliceStable(values, func(i, j int) bool {
if values[i].exp.Total != values[j].exp.Total {
return values[i].exp.Total > values[j].exp.Total
}
return values[i].item.Name < values[j].item.Name
})
out := make([]RankedItem, 0, minPositive(limit, len(values)))
genreCounts, peopleCounts := map[string]int{}, map[string]int{}
deferred := make([]scored, 0)
for _, value := range values {
genre := primaryGenre(value.item)
person := leadPerson(value.item)
if cfg.MaxPrimaryGenre > 0 && genre != "" && genreCounts[genre] >= cfg.MaxPrimaryGenre ||
cfg.MaxLeadPerson > 0 && person != "" && peopleCounts[person] >= cfg.MaxLeadPerson {
deferred = append(deferred, value)
continue
}
out = append(out, RankedItem{Item: value.item, Explanation: value.exp})
genreCounts[genre]++
peopleCounts[person]++
if limit > 0 && len(out) == limit {
break
}
}
for _, value := range deferred {
if limit > 0 && len(out) == limit {
break
}
out = append(out, RankedItem{Item: value.item, Explanation: value.exp})
}
applyExploration(out, cfg.ExplorationRate)
return out
}
func eligibleForIntent(
profile WeightedProfile,
item Item,
intent RankIntent,
cfg WeightedConfig,
now time.Time,
) bool {
if profile.ExplicitNegative[item.ID] {
return false
}
if intent.UnseenOnly && (profile.Seen[item.ID] || item.UserData.Played ||
item.UserData.PlaybackPositionTicks > 0) {
return false
}
if len(intent.ItemTypes) > 0 && !containsFold(intent.ItemTypes, item.Type) {
return false
}
if intent.MaxRuntimeMins > 0 && item.RuntimeMinutes() > intent.MaxRuntimeMins {
return false
}
if intent.NewReleasesOnly {
released, ok := item.ReleaseDate()
if !ok || released.After(now) || released.Before(now.AddDate(0, 0, -cfg.NewReleaseDays)) {
return false
}
}
return true
}
func scoreWeightedItem(
profile WeightedProfile,
item Item,
exposure ItemExposure,
intent RankIntent,
cfg WeightedConfig,
now time.Time,
) ScoreExplanation {
c := map[string]float64{}
reasons := []string{}
c["genre"] = affinitySum(profile.Genres, item.Genres, cfg.MinimumEvidence)
c["studio"] = affinitySum(profile.Studios, studioNames(item), cfg.MinimumEvidence)
c["actor"] = affinitySum(profile.Actors, peopleNames(item, "actor"), cfg.MinimumEvidence)
c["director"] = affinitySum(profile.Directors, peopleNames(item, "director"), cfg.MinimumEvidence)
c["franchise"] = affinitySum(profile.Franchises, []string{item.Franchise()}, cfg.MinimumEvidence)
c["runtime"] = affinitySum(profile.RuntimeRanges, []string{runtimeRange(item.RuntimeMinutes())}, cfg.MinimumEvidence)
c["ageRating"] = affinitySum(profile.AgeRatings, []string{item.OfficialRating}, cfg.MinimumEvidence)
c["communityRatingAffinity"] = affinitySum(
profile.CommunityRatings,
[]string{communityRatingRange(item.CommunityRating)},
cfg.MinimumEvidence,
)
c["releasePeriod"] = affinitySum(profile.ReleasePeriods, []string{releasePeriod(item.ProductionYear)}, cfg.MinimumEvidence)
c["contentType"] = affinitySum(profile.ContentTypes, []string{item.Type}, cfg.MinimumEvidence)
c["communityRating"] = item.CommunityRating / 10 * cfg.CommunityPriorWeight
c["household"] = intent.HouseholdScores[item.ID] * cfg.HouseholdPriorWeight
c["compatibility"] = intent.Compatibility[item.ID] * cfg.CompatibilityWeight
c["searchRelevance"] = intent.SearchRelevance[item.ID]
if profile.ExplicitPositive[item.ID] {
c["explicit"] = cfg.ExplicitPositiveBoost
reasons = append(reasons, "explicit_more_like_this")
}
if exposure.Impressions >= cfg.ImpressionFloor {
ignored := maxInt(0, exposure.Impressions-exposure.Focuses-exposure.Selects)
c["impressionFatigue"] = -float64(exposure.Impressions-cfg.ImpressionFloor+1)*cfg.ImpressionPenalty -
float64(ignored)*cfg.IgnoredPenalty
reasons = append(reasons, "impression_fatigue")
}
slot := currentContextSlot(now, intent.Location)
if profile.SessionEvidence[slot] >= cfg.MinimumEvidence && item.RuntimeMinutes() > 0 {
typical := profile.TypicalSessionMins[slot]
delta := math.Abs(float64(item.RuntimeMinutes()) - typical)
c["sessionFit"] = math.Max(-1, 1-delta/math.Max(20, typical)) * cfg.RuntimeContextWeight
if c["sessionFit"] > 0.25 {
reasons = append(reasons, "fits_session_length")
}
}
if intent.PreferShort && item.RuntimeMinutes() > 0 {
c["rowIntent"] = 1 / math.Max(1, float64(item.RuntimeMinutes())/30)
}
if intent.HiddenLibrary && !profile.Seen[item.ID] {
c["rowIntent"] += 0.7
reasons = append(reasons, "relevant_unseen")
}
for _, key := range []string{"genre", "studio", "actor", "director", "franchise"} {
if c[key] > 0.1 {
reasons = append(reasons, "affinity_"+strings.ToLower(key))
}
}
if profile.SourceEvents < cfg.MinimumEvidence {
reasons = append(reasons, "cold_start_priors")
}
total := 0.0
for _, value := range c {
total += value
}
return ScoreExplanation{Total: total, Components: c, Reasons: uniqueStrings(reasons)}
}
// EnrichRankedItem keeps diagnostics on the backend response while retaining Emby's
// original item contract.
func EnrichRankedItem(item RankedItem) json.RawMessage {
var payload map[string]any
if json.Unmarshal(item.Item.Raw, &payload) != nil || payload == nil {
payload = map[string]any{"Id": item.Item.ID, "Name": item.Item.Name, "Type": item.Item.Type}
}
payload["MembyRecommendationScore"] = item.Explanation.Total
payload["MembyRecommendationComponents"] = item.Explanation.Components
payload["MembyRecommendationReasonCodes"] = item.Explanation.Reasons
payload["MembyExploration"] = item.Explanation.Exploration
raw, _ := json.Marshal(payload)
return raw
}
func affinitySum(values map[string]Affinity, keys []string, minimum int) float64 {
score := 0.0
for _, key := range keys {
value := values[normalizeDimension(key)]
if value.Evidence >= minimum {
score += value.Weight / math.Sqrt(float64(value.Evidence))
}
}
if len(keys) > 1 {
score /= math.Sqrt(float64(len(keys)))
}
return score
}
func applyExploration(items []RankedItem, rate float64) {
if len(items) < 4 || rate <= 0 {
return
}
count := int(math.Round(float64(len(items)) * math.Min(0.2, rate)))
for n := 0; n < count; n++ {
from := len(items) - 1 - n
to := minPositive(3+n*5, from)
if from <= to {
continue
}
value := items[from]
copy(items[to+1:from+1], items[to:from])
value.Explanation.Exploration = true
value.Explanation.Reasons = append(value.Explanation.Reasons, "adjacent_exploration")
items[to] = value
}
}
func (i Item) Franchise() string {
if value := strings.TrimSpace(i.CollectionName); value != "" {
return value
}
// A conservative fallback only strips common sequel suffixes. It avoids inventing
// franchises from unrelated titles that happen to share one word.
parts := strings.Fields(i.Name)
if len(parts) > 1 {
last := strings.Trim(strings.ToLower(parts[len(parts)-1]), ":.-")
if isRomanNumeral(last) || strings.HasPrefix(last, "part") {
return strings.Join(parts[:len(parts)-1], " ")
}
}
return ""
}
func (i Item) ReleaseDate() (time.Time, bool) {
for _, value := range []string{i.PremiereDate, i.DateCreated} {
if parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value)); err == nil {
return parsed, true
}
}
if i.ProductionYear > 0 {
return time.Date(i.ProductionYear, 1, 1, 0, 0, 0, 0, time.UTC), true
}
return time.Time{}, false
}
func runtimeRange(minutes int) string {
switch {
case minutes <= 0:
return ""
case minutes <= 25:
return "short"
case minutes <= 50:
return "episode"
case minutes <= 100:
return "feature"
case minutes <= 150:
return "long-feature"
default:
return "epic"
}
}
func releasePeriod(year int) string {
switch {
case year <= 0:
return ""
case year < 1980:
return "classic"
case year < 2000:
return "1980s-1990s"
case year < 2015:
return "2000s-early-2010s"
default:
return "recent"
}
}
func communityRatingRange(rating float64) string {
switch {
case rating <= 0:
return ""
case rating < 6:
return "under-6"
case rating < 7.5:
return "6-to-7.4"
case rating < 8.5:
return "7.5-to-8.4"
default:
return "8.5-plus"
}
}
func currentContextSlot(now time.Time, location *time.Location) string {
if location != nil {
now = now.In(location)
}
return contextSlotKey(now.Weekday(), dayPart(now.Hour()))
}
func normalizeDimension(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
func studioNames(item Item) []string {
out := make([]string, 0, len(item.Studios))
for _, value := range item.Studios {
out = append(out, value.Name)
}
return out
}
func peopleNames(item Item, kind string) []string {
out := []string{}
for _, value := range item.People {
if strings.EqualFold(value.Type, kind) {
out = append(out, value.Name)
}
}
return out
}
func primaryGenre(item Item) string {
if len(item.Genres) == 0 {
return ""
}
return normalizeDimension(item.Genres[0])
}
func leadPerson(item Item) string {
for _, value := range item.People {
if strings.EqualFold(value.Type, "actor") {
return normalizeDimension(value.Name)
}
}
return ""
}
func containsFold(values []string, wanted string) bool {
for _, value := range values {
if strings.EqualFold(value, wanted) {
return true
}
}
return false
}
func uniqueStrings(values []string) []string {
seen, out := map[string]bool{}, []string{}
for _, value := range values {
if value != "" && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
return out
}
func isRomanNumeral(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if !strings.ContainsRune("ivxlcdm", r) {
return false
}
}
return true
}
func minPositive(a, b int) int {
if a <= 0 || b < a {
return b
}
return a
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// stableFraction is kept for deterministic future exploration bucketing.
func stableFraction(value string) float64 {
h := fnv.New32a()
_, _ = h.Write([]byte(value))
return float64(h.Sum32()) / float64(math.MaxUint32)
}
+162
View File
@@ -0,0 +1,162 @@
package recommend
import (
"encoding/json"
"testing"
"time"
)
func rankedFixture(id, name, kind string, genres []string, minutes int) Item {
raw, _ := json.Marshal(map[string]any{
"Id": id, "Name": name, "Type": kind, "Genres": genres,
"RunTimeTicks": int64(minutes) * 600_000_000,
})
item := Item{
ID: id, Name: name, Type: kind, Genres: genres,
RunTimeTicks: int64(minutes) * 600_000_000, CommunityRating: 7,
Raw: raw,
}
return item
}
func TestWeightedRankPersonalizesTwoUsersFromRepeatedCompletedPatterns(t *testing.T) {
now := time.Date(2026, 7, 31, 20, 0, 0, 0, time.UTC)
dramaHistory := []ViewingEvidence{
{Item: rankedFixture("d1", "Drama One", "Movie", []string{"Drama"}, 100), Completion: 1, OccurredAt: now.Add(-24 * time.Hour)},
{Item: rankedFixture("d2", "Drama Two", "Series", []string{"Drama"}, 45), Completion: .96, OccurredAt: now.Add(-48 * time.Hour)},
}
comedyHistory := []ViewingEvidence{
{Item: rankedFixture("c1", "Comedy One", "Movie", []string{"Comedy"}, 95), Completion: 1, OccurredAt: now.Add(-24 * time.Hour)},
{Item: rankedFixture("c2", "Comedy Two", "Series", []string{"Comedy"}, 25), Completion: .98, OccurredAt: now.Add(-48 * time.Hour)},
}
candidates := []Item{
rankedFixture("new-comedy", "A Comedy", "Movie", []string{"Comedy"}, 90),
rankedFixture("new-drama", "A Drama", "Movie", []string{"Drama"}, 90),
}
cfg := DefaultWeightedConfig()
drama := WeightedRank(BuildWeightedProfile(dramaHistory, now, time.UTC), candidates, nil, RankIntent{Now: now}, cfg, 10)
comedy := WeightedRank(BuildWeightedProfile(comedyHistory, now, time.UTC), candidates, nil, RankIntent{Now: now}, cfg, 10)
if drama[0].Item.ID != "new-drama" || comedy[0].Item.ID != "new-comedy" {
t.Fatalf("personalized first items = drama:%s comedy:%s", drama[0].Item.ID, comedy[0].Item.ID)
}
if drama[0].Explanation.Components["genre"] <= 0 ||
len(drama[0].Explanation.Reasons) == 0 {
t.Fatalf("missing explainable component scores: %#v", drama[0].Explanation)
}
}
func TestWeightedRankRequiresRepeatedAffinityEvidence(t *testing.T) {
now := time.Now()
profile := BuildWeightedProfile([]ViewingEvidence{{
Item: rankedFixture("once", "One Accidental Start", "Movie", []string{"Horror"}, 90),
Completion: .05, OccurredAt: now,
}}, now, time.UTC)
ranked := WeightedRank(profile, []Item{
rankedFixture("horror", "Horror", "Movie", []string{"Horror"}, 90),
rankedFixture("other", "Other", "Movie", []string{"Drama"}, 90),
}, nil, RankIntent{Now: now}, DefaultWeightedConfig(), 10)
for _, value := range ranked {
if value.Explanation.Components["genre"] != 0 {
t.Fatalf("one event established genre affinity: %#v", value.Explanation.Components)
}
}
}
func TestOnboardingRatingCreatesImmediateMetadataAffinity(t *testing.T) {
profile := WeightedProfile{}
rated := rankedFixture(
"rated", "Arrival", "Movie", []string{"Science Fiction", "Drama"}, 116,
)
rated.Studios = []struct {
Name string `json:"Name"`
}{{Name: "Paramount"}}
rated.People = []Person{
{Name: "Amy Adams", Type: "Actor"},
{Name: "Denis Villeneuve", Type: "Director"},
}
profile.ApplyOnboardingRating(rated, 5, 2)
candidate := rankedFixture(
"candidate", "Another Arrival", "Movie", []string{"Science Fiction"}, 120,
)
candidate.Studios = rated.Studios
candidate.People = rated.People
ranked := WeightedRank(
profile, []Item{candidate}, nil, RankIntent{}, DefaultWeightedConfig(), 1,
)
if len(ranked) != 1 {
t.Fatal("rated metadata produced no candidate")
}
components := ranked[0].Explanation.Components
for _, key := range []string{"genre", "studio", "actor", "director"} {
if components[key] <= 0 {
t.Fatalf("%s component = %.3f, want positive: %#v", key, components[key], components)
}
}
if !profile.Seen[rated.ID] {
t.Fatal("the explicitly rated title was not marked seen")
}
}
func TestWeightedRankNewReleaseEligibilityPrecedesPersonalization(t *testing.T) {
now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
old := rankedFixture("old", "Perfect Old Match", "Movie", []string{"Drama"}, 90)
old.PremiereDate = "2020-01-01T00:00:00Z"
fresh := rankedFixture("fresh", "Fresh Adjacent", "Movie", []string{"Comedy"}, 90)
fresh.PremiereDate = "2026-07-01T00:00:00Z"
profile := BuildWeightedProfile([]ViewingEvidence{
{Item: rankedFixture("d1", "D1", "Movie", []string{"Drama"}, 90), Completion: 1},
{Item: rankedFixture("d2", "D2", "Movie", []string{"Drama"}, 90), Completion: 1},
}, now, time.UTC)
ranked := WeightedRank(profile, []Item{old, fresh}, nil, RankIntent{
Now: now, NewReleasesOnly: true,
}, DefaultWeightedConfig(), 10)
if len(ranked) != 1 || ranked[0].Item.ID != "fresh" {
t.Fatalf("new release eligibility = %#v", ranked)
}
}
func TestWeightedRankExcludesNegativeAndPenalizesIgnoredImpressions(t *testing.T) {
now := time.Now()
profile := WeightedProfile{
Genres: map[string]Affinity{}, Studios: map[string]Affinity{},
Actors: map[string]Affinity{}, Directors: map[string]Affinity{},
Franchises: map[string]Affinity{}, RuntimeRanges: map[string]Affinity{},
AgeRatings: map[string]Affinity{}, ReleasePeriods: map[string]Affinity{},
ContentTypes: map[string]Affinity{}, Seen: map[string]bool{},
ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{"blocked": true},
TypicalSessionMins: map[string]float64{}, SessionEvidence: map[string]int{},
}
items := []Item{
rankedFixture("ignored", "Ignored", "Movie", nil, 90),
rankedFixture("fresh", "Fresh", "Movie", nil, 90),
rankedFixture("blocked", "Blocked", "Movie", nil, 90),
}
ranked := WeightedRank(profile, items, map[string]ItemExposure{
"ignored": {Impressions: 12},
}, RankIntent{Now: now}, DefaultWeightedConfig(), 10)
if len(ranked) != 2 || ranked[0].Item.ID != "fresh" {
t.Fatalf("fatigue/negative ordering = %#v", ranked)
}
}
func TestWeightedRankSessionFitSupportsBeforeBedIntent(t *testing.T) {
now := time.Date(2026, 7, 31, 23, 0, 0, 0, time.UTC)
slot := currentContextSlot(now, time.UTC)
profile := WeightedProfile{
Genres: map[string]Affinity{}, Studios: map[string]Affinity{},
Actors: map[string]Affinity{}, Directors: map[string]Affinity{},
Franchises: map[string]Affinity{}, RuntimeRanges: map[string]Affinity{},
AgeRatings: map[string]Affinity{}, ReleasePeriods: map[string]Affinity{},
ContentTypes: map[string]Affinity{}, Seen: map[string]bool{},
ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{},
TypicalSessionMins: map[string]float64{slot: 28}, SessionEvidence: map[string]int{slot: 5},
}
ranked := WeightedRank(profile, []Item{
rankedFixture("film", "Long Film", "Movie", nil, 125),
rankedFixture("episode", "One Episode", "Series", nil, 27),
}, nil, RankIntent{Now: now, Location: time.UTC, PreferShort: true}, DefaultWeightedConfig(), 10)
if ranked[0].Item.ID != "episode" {
t.Fatalf("bedtime ordering = %s", ranked[0].Item.ID)
}
}
+126 -7
View File
@@ -26,13 +26,65 @@ type Image struct {
}
type Series struct {
ID int `json:"id"`
Title string `json:"title"`
Overview string `json:"overview"`
Year int `json:"year"`
Network string `json:"network"`
Genres []string `json:"genres"`
Images []Image `json:"images"`
ID int `json:"id"`
TVDBID int `json:"tvdbId"`
Title string `json:"title"`
TitleSlug string `json:"titleSlug"`
Overview string `json:"overview"`
Year int `json:"year"`
Network string `json:"network"`
Genres []string `json:"genres"`
Images []Image `json:"images"`
RootFolderPath string `json:"rootFolderPath,omitempty"`
QualityProfileID int `json:"qualityProfileId,omitempty"`
Monitored bool `json:"monitored"`
SeasonFolder bool `json:"seasonFolder"`
Seasons []Season `json:"seasons"`
Status string `json:"status"`
NextAiring *time.Time `json:"nextAiring"`
}
// Series returns Sonarr's current catalogue, including lifecycle and next-airing data.
func (c *Client) Series(ctx context.Context) ([]Series, error) {
var series []Series
if err := c.get(ctx, "/api/v3/series", &series); err != nil {
return nil, err
}
return series, nil
}
// Episodes returns Sonarr's complete episode list for one series. Unlike the calendar,
// this includes future episodes, which is what makes finale detection trustworthy rather
// than mistaking the newest downloaded episode for the end of a season.
func (c *Client) Episodes(ctx context.Context, seriesID int) ([]Episode, error) {
if seriesID <= 0 {
return nil, fmt.Errorf("sonarr: invalid series id")
}
req, err := c.request(ctx, "/api/v3/episode", url.Values{
"seriesId": {strconv.Itoa(seriesID)},
"includeSeries": {"true"},
})
if err != nil {
return nil, err
}
var episodes []Episode
if err := c.do(req, &episodes); err != nil {
return nil, err
}
return episodes, nil
}
type Season struct {
SeasonNumber int `json:"seasonNumber"`
Monitored bool `json:"monitored"`
}
type RootFolder struct {
Path string `json:"path"`
}
type QualityProfile struct {
ID int `json:"id"`
}
type EpisodeFile struct {
@@ -101,6 +153,50 @@ func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Episode,
return episodes, nil
}
func (c *Client) Lookup(ctx context.Context, term string) ([]Series, error) {
req, err := c.request(ctx, "/api/v3/series/lookup", url.Values{"term": {term}})
if err != nil {
return nil, err
}
var series []Series
if err := c.do(req, &series); err != nil {
return nil, err
}
return series, nil
}
// AddUnmonitored adds a series without monitoring it or starting an episode search.
func (c *Client) AddUnmonitored(ctx context.Context, series Series) (Series, error) {
var roots []RootFolder
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
return Series{}, err
}
var profiles []QualityProfile
if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil {
return Series{}, err
}
if len(roots) == 0 || len(profiles) == 0 {
return Series{}, fmt.Errorf("sonarr: no root folder or quality profile configured")
}
series.ID = 0
series.RootFolderPath = roots[0].Path
series.QualityProfileID = profiles[0].ID
series.Monitored = false
series.SeasonFolder = true
for i := range series.Seasons {
series.Seasons[i].Monitored = false
}
body := struct {
Series
AddOptions map[string]bool `json:"addOptions"`
}{Series: series, AddOptions: map[string]bool{"searchForMissingEpisodes": false}}
var added Series
if err := c.post(ctx, "/api/v3/series", body, &added); err != nil {
return Series{}, err
}
return added, nil
}
// MediaCover fetches a series poster or fanart without exposing the Sonarr API key.
func (c *Client) MediaCover(ctx context.Context, seriesID int, coverType string) (*http.Response, error) {
if seriesID <= 0 || (coverType != "poster" && coverType != "fanart") {
@@ -138,6 +234,29 @@ func (c *Client) request(ctx context.Context, path string, params url.Values) (*
return req, nil
}
func (c *Client) get(ctx context.Context, path string, out any) error {
req, err := c.request(ctx, path, nil)
if err != nil {
return err
}
return c.do(req, out)
}
func (c *Client) post(ctx context.Context, path string, body, out any) error {
raw, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("sonarr: encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, strings.NewReader(string(raw)))
if err != nil {
return err
}
req.Header.Set("X-Api-Key", c.apiKey)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
return c.do(req, out)
}
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.http.Do(req)
if err != nil {
+46
View File
@@ -2,6 +2,7 @@ package sonarr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
@@ -45,6 +46,51 @@ func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
}
}
func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/rootfolder":
_, _ = w.Write([]byte(`[{"path":"/tv"}]`))
case "/api/v3/qualityprofile":
_, _ = w.Write([]byte(`[{"id":3}]`))
case "/api/v3/series":
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["monitored"] != false || body["seasonFolder"] != true ||
body["rootFolderPath"] != "/tv" || body["qualityProfileId"] != float64(3) {
t.Errorf("unexpected add body: %#v", body)
}
seasons := body["seasons"].([]any)
if seasons[0].(map[string]any)["monitored"] != false {
t.Errorf("season remained monitored: %#v", body)
}
options := body["addOptions"].(map[string]any)
if options["searchForMissingEpisodes"] != false {
t.Errorf("episode search was enabled: %#v", body)
}
_, _ = w.Write([]byte(`{"id":8,"tvdbId":44,"title":"Severance"}`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer upstream.Close()
added, err := New(upstream.URL, "secret", time.Second).AddUnmonitored(
context.Background(), Series{
TVDBID: 44, Title: "Severance", Monitored: true,
Seasons: []Season{{SeasonNumber: 1, Monitored: true}},
},
)
if err != nil {
t.Fatal(err)
}
if added.ID != 8 {
t.Fatalf("unexpected series: %+v", added)
}
}
func rHasAPIKey(query string) bool {
return strings.Contains(query, "secret")
}
+40
View File
@@ -78,6 +78,46 @@ type RowStat struct {
SelectRate float64 `json:"selectRate"`
}
// UserRowStats is the per-profile counterpart to the admin aggregate. It gives the
// home composer enough evidence to gently demote shelves that a viewer repeatedly
// passes over without turning a couple of accidental focus moves into a preference.
func (s *Store) UserRowStats(
ctx context.Context,
userID string,
since time.Time,
) ([]RowStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT row_id,
(array_agg(row_kind ORDER BY occurred_at DESC))[1] AS row_kind,
count(*) FILTER (WHERE event = 'impression') AS impressions,
count(*) FILTER (WHERE event = 'focus') AS focuses,
count(*) FILTER (WHERE event = 'select') AS selects,
coalesce(sum(dwell_ms), 0) AS dwell_ms
FROM row_events
WHERE emby_user_id = $1 AND occurred_at >= $2
GROUP BY row_id`, userID, since)
if err != nil {
return nil, fmt.Errorf("store: user row stats: %w", err)
}
defer rows.Close()
stats := []RowStat{}
for rows.Next() {
var stat RowStat
if err := rows.Scan(
&stat.RowID, &stat.RowKind, &stat.Impressions, &stat.Focuses,
&stat.Selects, &stat.DwellMs,
); err != nil {
return nil, err
}
stat.Viewers = 1
if stat.Impressions > 0 {
stat.SelectRate = float64(stat.Selects) / float64(stat.Impressions)
}
stats = append(stats, stat)
}
return stats, rows.Err()
}
func (s *Store) InsertRowEvents(ctx context.Context, events []RowEvent) error {
if len(events) == 0 {
return nil
+116 -17
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -17,6 +18,16 @@ type TracearrSessionKey struct {
SessionID string
}
type TracearrSessionSignal struct {
Fingerprint []byte
Terminal bool
}
type RecommendationIdentity struct {
TracearrUserID string
Username string
}
type TracearrSession struct {
ServerID string
SessionID string
@@ -65,7 +76,10 @@ type RecommendationProfile struct {
GenreAffinity json.RawMessage
TitleAffinity json.RawMessage
StudioAffinity json.RawMessage
ContextAffinity json.RawMessage
CodecOutcomes json.RawMessage
WeightedProfile json.RawMessage
AlgorithmVersion string
SignalsThrough *time.Time
BuiltAt time.Time
}
@@ -89,6 +103,8 @@ type ForYouCandidate struct {
type PreparedForYouItem struct {
ItemID string
BaseRank int
BaseScore float64
AffinityScore float64
Payload json.RawMessage
RuntimeMinutes int
CompatibilityScore float64
@@ -98,6 +114,7 @@ type PreparedForYouItem struct {
ReasonGenre string
ReasonSourceItemID string
ReasonSourceTitle string
ContextAffinity json.RawMessage
}
type ForYouStats struct {
@@ -151,6 +168,46 @@ func (s *Store) TracearrFingerprints(
return out, rows.Err()
}
func (s *Store) TracearrSessionSignals(
ctx context.Context,
keys []TracearrSessionKey,
) (map[TracearrSessionKey]TracearrSessionSignal, error) {
out := make(map[TracearrSessionKey]TracearrSessionSignal, len(keys))
if len(keys) == 0 {
return out, nil
}
servers := make([]string, 0, len(keys))
ids := make([]string, 0, len(keys))
for _, key := range keys {
servers = append(servers, key.ServerID)
ids = append(ids, key.SessionID)
}
rows, err := s.pool.Query(ctx, `
SELECT current.server_id, current.tracearr_session_id, current.source_fingerprint,
(current.watched OR current.stopped_at IS NOT NULL OR
lower(current.state) IN ('stopped', 'completed', 'complete', 'ended'))
FROM tracearr_sessions current
JOIN unnest($1::text[], $2::text[]) wanted(server_id, session_id)
ON current.server_id = wanted.server_id
AND current.tracearr_session_id = wanted.session_id`,
servers, ids)
if err != nil {
return nil, fmt.Errorf("store: tracearr session signals: %w", err)
}
defer rows.Close()
for rows.Next() {
var key TracearrSessionKey
var signal TracearrSessionSignal
if err := rows.Scan(
&key.ServerID, &key.SessionID, &signal.Fingerprint, &signal.Terminal,
); err != nil {
return nil, err
}
out[key] = signal
}
return out, rows.Err()
}
func (s *Store) UpsertTracearrSessions(
ctx context.Context,
sessions []TracearrSession,
@@ -354,7 +411,7 @@ func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (emby_user_id)
token_hash, emby_user_id, emby_token, username, server_id, device_id,
device_name, client_version, client_protocol, last_seen_at
device_name, client_version, client_protocol, client_capabilities, last_seen_at
FROM sessions
ORDER BY emby_user_id, last_seen_at DESC`)
if err != nil {
@@ -367,7 +424,7 @@ func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error
if err := rows.Scan(
&session.TokenHash, &session.EmbyUserID, &session.EmbyToken, &session.Username,
&session.ServerID, &session.DeviceID, &session.DeviceName, &session.ClientVersion,
&session.ClientProtocol, &session.LastSeenAt,
&session.ClientProtocol, &session.ClientCapabilities, &session.LastSeenAt,
); err != nil {
return nil, err
}
@@ -400,6 +457,35 @@ func (s *Store) MarkAllForYouProfilesDirty(ctx context.Context) error {
return nil
}
func (s *Store) MarkForYouProfilesDirtyByTracearrIdentity(
ctx context.Context,
identities []RecommendationIdentity,
) (int64, error) {
if len(identities) == 0 {
return 0, nil
}
userIDs := make([]string, 0, len(identities))
usernames := make([]string, 0, len(identities))
for _, identity := range identities {
if identity.TracearrUserID != "" {
userIDs = append(userIDs, identity.TracearrUserID)
}
if identity.Username != "" {
usernames = append(usernames, strings.ToLower(identity.Username))
}
}
tag, err := s.pool.Exec(ctx, `
UPDATE recommendation_user_profiles
SET dirty_since = coalesce(dirty_since, now())
WHERE tracearr_user_id = ANY($1)
OR lower(tracearr_username) = ANY($2)`,
userIDs, usernames)
if err != nil {
return 0, fmt.Errorf("store: mark affected For You profiles dirty: %w", err)
}
return tag.RowsAffected(), nil
}
func (s *Store) MatchRecommendationUser(
ctx context.Context,
embyUserID, embyUsername, tracearrUserID, tracearrUsername string,
@@ -431,18 +517,18 @@ func (s *Store) MatchRecommendationUser(
func (s *Store) ForYouProfileTimes(
ctx context.Context,
userID string,
) (builtAt, poolBuiltAt, dirtySince *time.Time, err error) {
) (builtAt, poolBuiltAt, dirtySince *time.Time, algorithmVersion string, err error) {
err = s.pool.QueryRow(ctx, `
SELECT built_at, pool_built_at, dirty_since
SELECT built_at, pool_built_at, dirty_since, algorithm_version
FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
Scan(&builtAt, &poolBuiltAt, &dirtySince)
Scan(&builtAt, &poolBuiltAt, &dirtySince, &algorithmVersion)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, nil, nil
return nil, nil, nil, "", nil
}
if err != nil {
return nil, nil, nil, fmt.Errorf("store: For You freshness: %w", err)
return nil, nil, nil, "", fmt.Errorf("store: For You freshness: %w", err)
}
return builtAt, poolBuiltAt, dirtySince, nil
return builtAt, poolBuiltAt, dirtySince, algorithmVersion, nil
}
func (s *Store) SetForYouError(ctx context.Context, userID string, buildErr error) error {
@@ -474,10 +560,11 @@ func (s *Store) ReplaceForYouPool(
INSERT INTO recommendation_user_profiles (
emby_user_id, tracearr_user_id, tracearr_username, source_session_count,
mean_completion_ratio, typical_session_minutes, genre_affinity,
title_affinity, studio_affinity, codec_outcomes, signals_through,
built_at, pool_built_at, dirty_since, last_error
title_affinity, studio_affinity, context_affinity, codec_outcomes,
weighted_profile, algorithm_version, signals_through, built_at, pool_built_at, dirty_since, last_error
) VALUES (
$1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11,$12,$12,NULL,''
$1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11::jsonb,
$12::jsonb,$13,$14,$15,$15,NULL,''
)
ON CONFLICT (emby_user_id) DO UPDATE SET
tracearr_user_id = EXCLUDED.tracearr_user_id,
@@ -488,7 +575,10 @@ func (s *Store) ReplaceForYouPool(
genre_affinity = EXCLUDED.genre_affinity,
title_affinity = EXCLUDED.title_affinity,
studio_affinity = EXCLUDED.studio_affinity,
context_affinity = EXCLUDED.context_affinity,
codec_outcomes = EXCLUDED.codec_outcomes,
weighted_profile = EXCLUDED.weighted_profile,
algorithm_version = EXCLUDED.algorithm_version,
signals_through = EXCLUDED.signals_through,
built_at = EXCLUDED.built_at,
pool_built_at = EXCLUDED.pool_built_at,
@@ -498,7 +588,9 @@ func (s *Store) ReplaceForYouPool(
profile.SourceSessionCount, profile.MeanCompletionRatio,
profile.TypicalSessionMinutes, string(profile.GenreAffinity),
string(profile.TitleAffinity), string(profile.StudioAffinity),
string(profile.CodecOutcomes), profile.SignalsThrough, profile.BuiltAt)
string(profile.ContextAffinity), string(profile.CodecOutcomes),
string(profile.WeightedProfile), profile.AlgorithmVersion,
profile.SignalsThrough, profile.BuiltAt)
if err != nil {
return fmt.Errorf("store: write For You profile: %w", err)
}
@@ -544,15 +636,20 @@ func (s *Store) PreparedForYou(
minutes, limit int,
) ([]PreparedForYouItem, *time.Time, error) {
rows, err := s.pool.Query(ctx, `
SELECT fc.item_id, fc.base_rank, li.payload, fc.runtime_minutes,
SELECT fc.item_id, fc.base_rank, fc.base_score, fc.affinity_score,
li.payload, fc.runtime_minutes,
fc.compatibility_score, fc.compatibility_label,
fc.recommendation_reason, fc.reason_kind, fc.reason_genre,
fc.reason_source_item_id, fc.reason_source_title, p.pool_built_at
fc.reason_source_item_id, fc.reason_source_title, p.context_affinity,
p.pool_built_at
FROM for_you_candidates fc
JOIN library_items li ON li.id = fc.item_id
JOIN recommendation_user_profiles p ON p.emby_user_id = fc.emby_user_id
WHERE fc.emby_user_id = $1
AND ($2 = 0 OR (fc.runtime_minutes > 0 AND fc.runtime_minutes <= $2))
AND (
$2 = 0 OR fc.reason_kind = 'pick-up' OR
(fc.runtime_minutes > 0 AND fc.runtime_minutes <= $2)
)
ORDER BY fc.base_rank
LIMIT $3`,
userID, minutes, limit)
@@ -567,10 +664,12 @@ func (s *Store) PreparedForYou(
var item PreparedForYouItem
var rowBuiltAt *time.Time
if err := rows.Scan(
&item.ItemID, &item.BaseRank, &payload, &item.RuntimeMinutes,
&item.ItemID, &item.BaseRank, &item.BaseScore, &item.AffinityScore,
&payload, &item.RuntimeMinutes,
&item.CompatibilityScore, &item.CompatibilityLabel,
&item.RecommendationReason, &item.ReasonKind, &item.ReasonGenre,
&item.ReasonSourceItemID, &item.ReasonSourceTitle, &rowBuiltAt,
&item.ReasonSourceItemID, &item.ReasonSourceTitle, &item.ContextAffinity,
&rowBuiltAt,
); err != nil {
return nil, nil, err
}
+109 -24
View File
@@ -34,7 +34,8 @@ type LibraryStats struct {
LastSynced *time.Time `json:"lastSynced"`
}
// UpsertLibraryItems writes a batch, refreshing synced_at on every row it touches.
// UpsertLibraryItems writes a batch, refreshing synced_at on every row it touches, and
// returns how many recommendation-relevant payloads were inserted or actually changed.
//
// synced_at doubles as the mark-and-sweep marker: a full import stamps everything it
// sees, then deletes whatever kept an older stamp.
@@ -46,23 +47,48 @@ func (s *Store) UpsertLibraryItems(ctx context.Context, items []LibraryItem, syn
batch := &pgx.Batch{}
for _, item := range items {
batch.Queue(`
INSERT INTO library_items (
id, type, name, series_id, series_name, production_year, community_rating,
genres, studios, date_created, search_text, payload, synced_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13)
ON CONFLICT (id) DO UPDATE SET
type = EXCLUDED.type,
name = EXCLUDED.name,
series_id = EXCLUDED.series_id,
series_name = EXCLUDED.series_name,
production_year = EXCLUDED.production_year,
community_rating = EXCLUDED.community_rating,
genres = EXCLUDED.genres,
studios = EXCLUDED.studios,
date_created = EXCLUDED.date_created,
search_text = EXCLUDED.search_text,
payload = EXCLUDED.payload,
synced_at = EXCLUDED.synced_at`,
WITH previous AS MATERIALIZED (
SELECT type, name, series_id, series_name, production_year,
community_rating, genres, studios, date_created,
payload->'RunTimeTicks' AS runtime_ticks,
payload->'MediaStreams' AS media_streams,
payload->'Container' AS container
FROM library_items WHERE id = $1
), upserted AS (
INSERT INTO library_items (
id, type, name, series_id, series_name, production_year, community_rating,
genres, studios, date_created, search_text, payload, synced_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13)
ON CONFLICT (id) DO UPDATE SET
type = EXCLUDED.type,
name = EXCLUDED.name,
series_id = EXCLUDED.series_id,
series_name = EXCLUDED.series_name,
production_year = EXCLUDED.production_year,
community_rating = EXCLUDED.community_rating,
genres = EXCLUDED.genres,
studios = EXCLUDED.studios,
date_created = EXCLUDED.date_created,
search_text = EXCLUDED.search_text,
payload = EXCLUDED.payload,
synced_at = EXCLUDED.synced_at
RETURNING 1
)
SELECT NOT EXISTS (SELECT 1 FROM previous)
OR EXISTS (
SELECT 1 FROM previous
WHERE ROW(
type, name, series_id, series_name, production_year,
community_rating, genres, studios, date_created,
runtime_ticks, media_streams, container
) IS DISTINCT FROM ROW(
$2::text, $3::text, $4::text, $5::text, $6::int,
$7::real, $8::text[], $9::text[], $10::timestamptz,
$12::jsonb->'RunTimeTicks', $12::jsonb->'MediaStreams',
$12::jsonb->'Container'
)
)
FROM upserted`,
item.ID, item.Type, item.Name, item.SeriesID, item.SeriesName,
item.ProductionYear, item.CommunityRating, item.Genres, item.Studios,
item.DateCreated, item.SearchText, string(item.Payload), syncedAt)
@@ -71,15 +97,17 @@ func (s *Store) UpsertLibraryItems(ctx context.Context, items []LibraryItem, syn
results := s.pool.SendBatch(ctx, batch)
defer results.Close()
var written int64
var changed int64
for range items {
tag, err := results.Exec()
if err != nil {
return written, fmt.Errorf("store: upsert library items: %w", err)
var recommendationChanged bool
if err := results.QueryRow().Scan(&recommendationChanged); err != nil {
return changed, fmt.Errorf("store: upsert library items: %w", err)
}
if recommendationChanged {
changed++
}
written += tag.RowsAffected()
}
return written, nil
return changed, nil
}
// DeleteLibraryItemsBefore removes anything a full import did not touch — items deleted
@@ -153,6 +181,23 @@ func (s *Store) AllRecommendationCandidates(ctx context.Context) ([]json.RawMess
return collectPayloads(rows)
}
func (s *Store) LibraryItemsByID(
ctx context.Context,
ids []string,
) ([]json.RawMessage, error) {
if len(ids) == 0 {
return []json.RawMessage{}, nil
}
rows, err := s.pool.Query(ctx, `
SELECT payload
FROM library_items
WHERE id = ANY($1)`, ids)
if err != nil {
return nil, fmt.Errorf("store: library items by id: %w", err)
}
return collectPayloads(rows)
}
// CuratedCandidates filters the imported catalogue for a server-authored shelf. Arrays
// are matched case-insensitively because Emby studio capitalisation is not consistent.
func (s *Store) CuratedCandidates(
@@ -190,6 +235,46 @@ func (s *Store) CuratedCandidates(
return collectPayloads(rows)
}
// LibraryGenres returns every genre with enough catalogue depth to make a shelf feel
// intentional. The recommendation engine still applies per-user seen filtering and may
// drop a shelf afterwards when too few unseen titles remain.
func (s *Store) LibraryGenres(
ctx context.Context,
itemTypes []string,
minItems int,
) ([]string, error) {
if len(itemTypes) == 0 {
return nil, nil
}
if minItems < 1 {
minItems = 1
}
rows, err := s.pool.Query(ctx, `
SELECT genre, count(DISTINCT item.id) AS item_count
FROM library_items AS item
CROSS JOIN LATERAL unnest(item.genres) AS genre
WHERE item.type = ANY($1)
AND btrim(genre) <> ''
GROUP BY genre
HAVING count(DISTINCT item.id) >= $2
ORDER BY item_count DESC, lower(genre) ASC`,
itemTypes, minItems)
if err != nil {
return nil, fmt.Errorf("store: library genres: %w", err)
}
defer rows.Close()
out := []string{}
for rows.Next() {
var genre string
var count int
if err := rows.Scan(&genre, &count); err != nil {
return nil, err
}
out = append(out, genre)
}
return out, rows.Err()
}
func lowerStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
+175
View File
@@ -0,0 +1,175 @@
package store
import (
"context"
"fmt"
"time"
)
type UserShow struct {
ItemID string `json:"itemId"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
ImageTag string `json:"imageTag,omitempty"`
AddedAt time.Time `json:"addedAt"`
}
type NotificationPreferences struct {
Enabled bool `json:"enabled"`
ShowReturnAlerts bool `json:"showReturnAlerts"`
LeadDays int `json:"leadDays"`
}
type UserNotification struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
ItemID string `json:"itemId,omitempty"`
Title string `json:"title"`
Message string `json:"message"`
EventAt *time.Time `json:"eventAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
ReadAt *time.Time `json:"readAt,omitempty"`
}
func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (emby_user_id, item_id) DO UPDATE
SET title = EXCLUDED.title, year = EXCLUDED.year, image_tag = EXCLUDED.image_tag`,
userID, show.ItemID, show.Title, show.Year, show.ImageTag)
return err
}
// SaveUserShowIfAbsent is the auto-follow path. Manual saves intentionally refresh
// metadata, while playback must only announce a show when it actually added it.
func (s *Store) SaveUserShowIfAbsent(ctx context.Context, userID string, show UserShow) (bool, error) {
result, err := s.pool.Exec(ctx, `
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (emby_user_id, item_id) DO NOTHING`,
userID, show.ItemID, show.Title, show.Year, show.ImageTag)
if err != nil {
return false, err
}
return result.RowsAffected() == 1, nil
}
func (s *Store) DeleteUserShow(ctx context.Context, userID, itemID string) error {
_, err := s.pool.Exec(ctx, `
WITH removed AS (
DELETE FROM user_shows
WHERE emby_user_id = $1 AND item_id = $2
RETURNING item_id
)
UPDATE user_notifications SET dismissed_at = now()
WHERE emby_user_id = $1
AND item_id IN (SELECT item_id FROM removed)
AND dismissed_at IS NULL`,
userID, itemID)
return err
}
func (s *Store) UserShows(ctx context.Context, userID string) ([]UserShow, error) {
rows, err := s.pool.Query(ctx, `
SELECT item_id, title, year, image_tag, added_at
FROM user_shows WHERE emby_user_id = $1 ORDER BY added_at, lower(title)`, userID)
if err != nil {
return nil, fmt.Errorf("store: list user shows: %w", err)
}
defer rows.Close()
shows := []UserShow{}
for rows.Next() {
var show UserShow
if err := rows.Scan(&show.ItemID, &show.Title, &show.Year, &show.ImageTag, &show.AddedAt); err != nil {
return nil, fmt.Errorf("store: scan user show: %w", err)
}
shows = append(shows, show)
}
return shows, rows.Err()
}
func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) {
prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7}
err := s.pool.QueryRow(ctx, `
SELECT enabled, show_return_alerts, lead_days
FROM user_notification_preferences WHERE emby_user_id = $1`, userID).
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.LeadDays)
if err != nil && !isNoRows(err) {
return prefs, fmt.Errorf("store: notification preferences: %w", err)
}
return prefs, nil
}
func (s *Store) SetNotificationPreferences(
ctx context.Context, userID string, prefs NotificationPreferences,
) error {
if prefs.LeadDays < 1 {
prefs.LeadDays = 1
}
if prefs.LeadDays > 30 {
prefs.LeadDays = 30
}
_, err := s.pool.Exec(ctx, `
INSERT INTO user_notification_preferences
(emby_user_id, enabled, show_return_alerts, lead_days)
VALUES ($1, $2, $3, $4)
ON CONFLICT (emby_user_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
show_return_alerts = EXCLUDED.show_return_alerts,
lead_days = EXCLUDED.lead_days,
updated_at = now()`,
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.LeadDays)
return err
}
func (s *Store) UpsertNotification(
ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time,
) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO user_notifications
(emby_user_id, source_key, kind, item_id, title, message, event_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (emby_user_id, source_key) DO NOTHING`,
userID, sourceKey, kind, itemID, title, message, eventAt)
return err
}
func (s *Store) UserNotifications(ctx context.Context, userID string) ([]UserNotification, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, kind, item_id, title, message, event_at, created_at, read_at
FROM user_notifications
WHERE emby_user_id = $1 AND dismissed_at IS NULL
ORDER BY created_at DESC LIMIT 100`, userID)
if err != nil {
return nil, fmt.Errorf("store: list notifications: %w", err)
}
defer rows.Close()
notifications := []UserNotification{}
for rows.Next() {
var notification UserNotification
if err := rows.Scan(
&notification.ID, &notification.Kind, &notification.ItemID,
&notification.Title, &notification.Message, &notification.EventAt,
&notification.CreatedAt, &notification.ReadAt,
); err != nil {
return nil, fmt.Errorf("store: scan notification: %w", err)
}
notifications = append(notifications, notification)
}
return notifications, rows.Err()
}
func (s *Store) MarkNotificationRead(ctx context.Context, userID string, id int64) error {
_, err := s.pool.Exec(ctx, `
UPDATE user_notifications SET read_at = COALESCE(read_at, now())
WHERE id = $1 AND emby_user_id = $2`, id, userID)
return err
}
func (s *Store) DismissNotification(ctx context.Context, userID string, id int64) error {
_, err := s.pool.Exec(ctx, `
UPDATE user_notifications SET dismissed_at = now()
WHERE id = $1 AND emby_user_id = $2`, id, userID)
return err
}
+196
View File
@@ -0,0 +1,196 @@
package store
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
type RecommendationAction struct {
ItemID string `json:"itemId"`
Action string `json:"action"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ItemExposureStat struct {
ItemID string
Impressions int
Focuses int
Selects int
LastShown time.Time
}
func (s *Store) HouseholdCompletionScores(
ctx context.Context,
since time.Time,
) (map[string]float64, error) {
rows, err := s.pool.Query(ctx, `
SELECT coalesce(nullif(emby_series_id, ''), emby_item_id) AS item_id,
count(DISTINCT lower(username))::float8
FROM tracearr_sessions
WHERE started_at >= $1
AND (watched OR (
total_duration_ms > 0 AND progress_ms::float8 / total_duration_ms >= 0.9
))
AND coalesce(nullif(emby_series_id, ''), emby_item_id) <> ''
GROUP BY item_id`, since)
if err != nil {
return nil, fmt.Errorf("store: household completion scores: %w", err)
}
defer rows.Close()
out := map[string]float64{}
maxScore := 0.0
for rows.Next() {
var id string
var score float64
if err := rows.Scan(&id, &score); err != nil {
return nil, err
}
out[id] = score
if score > maxScore {
maxScore = score
}
}
if maxScore > 0 {
for id, score := range out {
out[id] = score / maxScore
}
}
return out, rows.Err()
}
func (s *Store) WeightedRecommendationProfile(
ctx context.Context,
userID string,
) (json.RawMessage, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `
SELECT weighted_profile
FROM recommendation_user_profiles
WHERE emby_user_id = $1`, userID).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return json.RawMessage(`{}`), nil
}
if err != nil {
return nil, fmt.Errorf("store: weighted recommendation profile: %w", err)
}
return json.RawMessage(raw), nil
}
func (s *Store) SetRecommendationAction(
ctx context.Context,
userID, itemID, action string,
) error {
if action != "more_like_this" && action != "not_for_me" {
return fmt.Errorf("store: invalid recommendation action %q", action)
}
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_actions (emby_user_id, item_id, action, updated_at)
VALUES ($1,$2,$3,now())
ON CONFLICT (emby_user_id, item_id) DO UPDATE
SET action = EXCLUDED.action, updated_at = now()`,
userID, itemID, action)
if err != nil {
return fmt.Errorf("store: set recommendation action: %w", err)
}
return nil
}
func (s *Store) ClearRecommendationAction(
ctx context.Context,
userID, itemID string,
) error {
_, err := s.pool.Exec(ctx, `
DELETE FROM recommendation_actions
WHERE emby_user_id = $1 AND item_id = $2`, userID, itemID)
return err
}
func (s *Store) RecommendationActions(
ctx context.Context,
userID string,
) ([]RecommendationAction, error) {
rows, err := s.pool.Query(ctx, `
SELECT item_id, action, updated_at
FROM recommendation_actions
WHERE emby_user_id = $1`, userID)
if err != nil {
return nil, fmt.Errorf("store: recommendation actions: %w", err)
}
defer rows.Close()
out := []RecommendationAction{}
for rows.Next() {
var value RecommendationAction
if err := rows.Scan(&value.ItemID, &value.Action, &value.UpdatedAt); err != nil {
return nil, err
}
out = append(out, value)
}
return out, rows.Err()
}
func (s *Store) RecommendationOnboarding(
ctx context.Context,
userID string,
) (json.RawMessage, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `
SELECT preferences FROM recommendation_onboarding WHERE emby_user_id = $1`,
userID).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return json.RawMessage(`{}`), nil
}
return json.RawMessage(raw), err
}
func (s *Store) SetRecommendationOnboarding(
ctx context.Context,
userID string,
preferences json.RawMessage,
) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO recommendation_onboarding (emby_user_id, preferences, updated_at)
VALUES ($1,$2::jsonb,now())
ON CONFLICT (emby_user_id) DO UPDATE
SET preferences = EXCLUDED.preferences, updated_at = now()`,
userID, string(preferences))
return err
}
// UserItemExposures is deliberately item-scoped. A row impression with no item id is
// useful for row ordering but cannot be used to claim a particular poster was ignored.
func (s *Store) UserItemExposures(
ctx context.Context,
userID string,
since time.Time,
) ([]ItemExposureStat, error) {
rows, err := s.pool.Query(ctx, `
SELECT item_id,
count(*) FILTER (WHERE event = 'impression'),
count(*) FILTER (WHERE event = 'focus'),
count(*) FILTER (WHERE event = 'select'),
max(occurred_at)
FROM row_events
WHERE emby_user_id = $1 AND occurred_at >= $2 AND item_id <> ''
GROUP BY item_id`, userID, since)
if err != nil {
return nil, fmt.Errorf("store: user item exposures: %w", err)
}
defer rows.Close()
out := []ItemExposureStat{}
for rows.Next() {
var value ItemExposureStat
if err := rows.Scan(
&value.ItemID, &value.Impressions, &value.Focuses,
&value.Selects, &value.LastShown,
); err != nil {
return nil, err
}
out = append(out, value)
}
return out, rows.Err()
}
+75
View File
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS sessions (
device_name TEXT NOT NULL DEFAULT 'Memby TV',
client_version TEXT NOT NULL DEFAULT '',
client_protocol TEXT NOT NULL DEFAULT '',
client_capabilities TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -20,6 +21,7 @@ CREATE TABLE IF NOT EXISTS sessions (
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_name TEXT NOT NULL DEFAULT 'Memby TV';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_version TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_protocol TEXT NOT NULL DEFAULT '';
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_capabilities TEXT[] NOT NULL DEFAULT '{}';
-- Older builds could create more than one token for the same physical TV. Keep the most
-- recently used row before adding the identity constraint.
@@ -119,6 +121,50 @@ CREATE TABLE IF NOT EXISTS search_history (
CREATE INDEX IF NOT EXISTS search_history_user_time_idx
ON search_history (emby_user_id, occurred_at DESC);
-- A user's explicitly followed TV series. Unlike library_items this is intentionally
-- user-scoped: following a show is a Memby preference, not Emby library state.
CREATE TABLE IF NOT EXISTS user_shows (
emby_user_id TEXT NOT NULL,
item_id TEXT NOT NULL,
title TEXT NOT NULL,
year INT,
image_tag TEXT NOT NULL DEFAULT '',
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, item_id)
);
CREATE INDEX IF NOT EXISTS user_shows_user_added_idx
ON user_shows (emby_user_id, added_at);
CREATE TABLE IF NOT EXISTS user_notification_preferences (
emby_user_id TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT true,
show_return_alerts BOOLEAN NOT NULL DEFAULT true,
lead_days INT NOT NULL DEFAULT 7 CHECK (lead_days BETWEEN 1 AND 30),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Notifications are materialised so read/dismissed state follows the user to every TV.
-- source_key is deterministic, preventing the same return date from being announced
-- again whenever the app refreshes.
CREATE TABLE IF NOT EXISTS user_notifications (
id BIGSERIAL PRIMARY KEY,
emby_user_id TEXT NOT NULL,
source_key TEXT NOT NULL,
kind TEXT NOT NULL,
item_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
message TEXT NOT NULL,
event_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
read_at TIMESTAMPTZ,
dismissed_at TIMESTAMPTZ,
UNIQUE (emby_user_id, source_key)
);
CREATE INDEX IF NOT EXISTS user_notifications_user_created_idx
ON user_notifications (emby_user_id, created_at DESC);
-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
-- since cursor, so stable source ids make these rows the durable deduplication boundary.
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
@@ -180,7 +226,10 @@ CREATE TABLE IF NOT EXISTS recommendation_user_profiles (
genre_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
title_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
studio_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
context_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
codec_outcomes JSONB NOT NULL DEFAULT '{}'::jsonb,
weighted_profile JSONB NOT NULL DEFAULT '{}'::jsonb,
algorithm_version TEXT NOT NULL DEFAULT '',
signals_through TIMESTAMPTZ,
built_at TIMESTAMPTZ,
pool_built_at TIMESTAMPTZ,
@@ -188,6 +237,32 @@ CREATE TABLE IF NOT EXISTS recommendation_user_profiles (
last_error TEXT NOT NULL DEFAULT ''
);
ALTER TABLE recommendation_user_profiles
ADD COLUMN IF NOT EXISTS context_affinity JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE recommendation_user_profiles
ADD COLUMN IF NOT EXISTS algorithm_version TEXT NOT NULL DEFAULT '';
ALTER TABLE recommendation_user_profiles
ADD COLUMN IF NOT EXISTS weighted_profile JSONB NOT NULL DEFAULT '{}'::jsonb;
-- Explicit recommendation feedback is separate from Emby favourites: More Like This
-- changes discovery affinity, while Not for Me is a hard user-scoped exclusion.
CREATE TABLE IF NOT EXISTS recommendation_actions (
emby_user_id TEXT NOT NULL,
item_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('more_like_this', 'not_for_me')),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (emby_user_id, item_id)
);
CREATE INDEX IF NOT EXISTS recommendation_actions_user_idx
ON recommendation_actions (emby_user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS recommendation_onboarding (
emby_user_id TEXT PRIMARY KEY,
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS recommendation_profiles_dirty_idx
ON recommendation_user_profiles (dirty_since)
WHERE dirty_since IS NOT NULL;
+214 -2
View File
@@ -14,6 +14,218 @@ import (
// MaintenanceKey is the app_settings row backing maintenance mode.
const MaintenanceKey = "maintenance"
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
const RequestPolicyKey = "request_policy"
// PlaybackPolicyKey controls presentation behavior that should be adjustable without
// shipping a new TV build.
const PlaybackPolicyKey = "playback_policy"
// FeaturePolicyKey is the durable operator control plane for optional behaviour.
// The catalogue of valid flags lives in the API; the store only persists overrides so
// removing or renaming a feature does not strand an unreadable database row.
const FeaturePolicyKey = "feature_policy"
type FeaturePolicySnapshot struct {
Overrides map[string]bool `json:"overrides"`
SafeMode bool `json:"safeMode"`
Revision int64 `json:"revision"`
UpdatedAt time.Time `json:"updatedAt"`
}
type FeaturePolicy struct {
Overrides map[string]bool `json:"overrides"`
SafeMode bool `json:"safeMode"`
Revision int64 `json:"revision"`
UpdatedAt time.Time `json:"updatedAt"`
Previous *FeaturePolicySnapshot `json:"previous,omitempty"`
}
var ErrFeaturePolicyConflict = errors.New("store: feature policy revision conflict")
func DefaultFeaturePolicy() FeaturePolicy {
return FeaturePolicy{Overrides: map[string]bool{}}
}
func normalizeFeaturePolicy(policy FeaturePolicy) FeaturePolicy {
if policy.Overrides == nil {
policy.Overrides = map[string]bool{}
}
return policy
}
func (s *Store) FeaturePolicy(ctx context.Context) (FeaturePolicy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, FeaturePolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return DefaultFeaturePolicy(), nil
}
if err != nil {
return DefaultFeaturePolicy(), fmt.Errorf("store: read feature policy: %w", err)
}
var policy FeaturePolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return DefaultFeaturePolicy(), fmt.Errorf("store: decode feature policy: %w", err)
}
return normalizeFeaturePolicy(policy), nil
}
// SetFeaturePolicy preserves the prior revision inside the same durable document. This
// gives the operator a recovery button without requiring a matching client release.
func (s *Store) SetFeaturePolicy(
ctx context.Context, next FeaturePolicy, expectedRevision int64,
) (FeaturePolicy, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return FeaturePolicy{}, fmt.Errorf("store: begin feature policy write: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, FeaturePolicyKey); err != nil {
return FeaturePolicy{}, fmt.Errorf("store: lock feature policy: %w", err)
}
current := DefaultFeaturePolicy()
var currentRaw []byte
err = tx.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, FeaturePolicyKey).Scan(&currentRaw)
if err == nil {
if err := json.Unmarshal(currentRaw, &current); err != nil {
return FeaturePolicy{}, fmt.Errorf("store: decode current feature policy: %w", err)
}
current = normalizeFeaturePolicy(current)
} else if !errors.Is(err, pgx.ErrNoRows) {
return FeaturePolicy{}, fmt.Errorf("store: read current feature policy: %w", err)
}
if current.Revision != expectedRevision {
return FeaturePolicy{}, ErrFeaturePolicyConflict
}
next = normalizeFeaturePolicy(next)
next.Revision = current.Revision + 1
next.UpdatedAt = time.Now().UTC()
next.Previous = &FeaturePolicySnapshot{
Overrides: current.Overrides, SafeMode: current.SafeMode,
Revision: current.Revision, UpdatedAt: current.UpdatedAt,
}
raw, err := json.Marshal(next)
if err != nil {
return FeaturePolicy{}, err
}
_, err = tx.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
FeaturePolicyKey, string(raw))
if err != nil {
return FeaturePolicy{}, fmt.Errorf("store: write feature policy: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return FeaturePolicy{}, fmt.Errorf("store: commit feature policy: %w", err)
}
return next, nil
}
const DefaultPrerollDurationMs int64 = 6_500
type PlaybackPolicy struct {
PrerollEnabled bool `json:"prerollEnabled"`
PrerollDurationMs int64 `json:"prerollDurationMs"`
UpdatedAt time.Time `json:"updatedAt"`
}
func DefaultPlaybackPolicy() PlaybackPolicy {
return PlaybackPolicy{PrerollEnabled: true, PrerollDurationMs: DefaultPrerollDurationMs}
}
func normalizePlaybackPolicy(policy PlaybackPolicy) PlaybackPolicy {
if policy.PrerollDurationMs == 0 {
policy.PrerollDurationMs = DefaultPrerollDurationMs
}
policy.PrerollDurationMs = max(int64(1_000), min(policy.PrerollDurationMs, int64(30_000)))
return policy
}
func (s *Store) PlaybackPolicy(ctx context.Context) (PlaybackPolicy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, PlaybackPolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return DefaultPlaybackPolicy(), nil
}
if err != nil {
return DefaultPlaybackPolicy(), fmt.Errorf("store: read playback policy: %w", err)
}
var policy PlaybackPolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return DefaultPlaybackPolicy(), fmt.Errorf("store: decode playback policy: %w", err)
}
return normalizePlaybackPolicy(policy), nil
}
func (s *Store) SetPlaybackPolicy(ctx context.Context, policy PlaybackPolicy) error {
policy = normalizePlaybackPolicy(policy)
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
PlaybackPolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write playback policy: %w", err)
}
return nil
}
type RequestPolicy struct {
AllowedUserIDs []string `json:"allowedUserIds"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (p RequestPolicy) Allows(userID string) bool {
for _, allowed := range p.AllowedUserIDs {
if allowed == userID {
return true
}
}
return false
}
func (s *Store) RequestPolicy(ctx context.Context) (RequestPolicy, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, RequestPolicyKey).Scan(&raw)
if errors.Is(err, pgx.ErrNoRows) {
return RequestPolicy{AllowedUserIDs: []string{}}, nil
}
if err != nil {
return RequestPolicy{}, fmt.Errorf("store: read request policy: %w", err)
}
var policy RequestPolicy
if err := json.Unmarshal(raw, &policy); err != nil {
return RequestPolicy{}, fmt.Errorf("store: decode request policy: %w", err)
}
if policy.AllowedUserIDs == nil {
policy.AllowedUserIDs = []string{}
}
return policy, nil
}
func (s *Store) SetRequestPolicy(ctx context.Context, policy RequestPolicy) error {
policy.UpdatedAt = time.Now().UTC()
raw, err := json.Marshal(policy)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO app_settings (key, value, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
RequestPolicyKey, string(raw))
if err != nil {
return fmt.Errorf("store: write request policy: %w", err)
}
return nil
}
// Maintenance is the operator switch that takes Memby down independently of Emby.
//
// Deliberately durable: a restart must not quietly bring the app back up while someone
@@ -105,11 +317,11 @@ func (s *Store) NewestSession(ctx context.Context) (Session, error) {
var sess Session
err := s.pool.QueryRow(ctx, `
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id,
device_name, client_version, client_protocol, last_seen_at
device_name, client_version, client_protocol, client_capabilities, last_seen_at
FROM sessions ORDER BY last_seen_at DESC LIMIT 1`).
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.LastSeenAt)
&sess.ClientProtocol, &sess.ClientCapabilities, &sess.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
+37
View File
@@ -0,0 +1,37 @@
package store
import "testing"
func TestRequestPolicyAllowsOnlyListedUsers(t *testing.T) {
policy := RequestPolicy{AllowedUserIDs: []string{"user-2"}}
if policy.Allows("user-1") {
t.Fatal("unlisted user was allowed")
}
if !policy.Allows("user-2") {
t.Fatal("listed user was denied")
}
}
func TestPlaybackPolicyDefaultsAndClampsDuration(t *testing.T) {
defaults := DefaultPlaybackPolicy()
if !defaults.PrerollEnabled || defaults.PrerollDurationMs != 6_500 {
t.Fatalf("default playback policy = %+v", defaults)
}
if got := normalizePlaybackPolicy(PlaybackPolicy{PrerollDurationMs: 500}); got.PrerollDurationMs != 1_000 {
t.Fatalf("short duration = %d, want 1000", got.PrerollDurationMs)
}
if got := normalizePlaybackPolicy(PlaybackPolicy{PrerollDurationMs: 60_000}); got.PrerollDurationMs != 30_000 {
t.Fatalf("long duration = %d, want 30000", got.PrerollDurationMs)
}
}
func TestFeaturePolicyDefaultsAreRecoverable(t *testing.T) {
policy := normalizeFeaturePolicy(FeaturePolicy{})
if policy.Overrides == nil || policy.SafeMode || policy.Revision != 0 {
t.Fatalf("default feature policy = %+v", policy)
}
policy.Overrides["sonarr_preroll"] = false
if policy.Overrides["sonarr_preroll"] {
t.Fatal("explicit off override was not retained")
}
}
+151 -91
View File
@@ -17,19 +17,80 @@ var schema string
// ErrNotFound is returned when a token does not match a live session.
var ErrNotFound = errors.New("store: session not found")
var ErrDeviceLimit = errors.New("store: device limit reached")
func isNoRows(err error) bool { return errors.Is(err, pgx.ErrNoRows) }
type Session struct {
TokenHash []byte
EmbyUserID string
EmbyToken string
Username string
ServerID string
DeviceID string
DeviceName string
ClientVersion string
ClientProtocol string
LastSeenAt time.Time
TokenHash []byte
EmbyUserID string
EmbyToken string
Username string
ServerID string
DeviceID string
DeviceName string
ClientVersion string
ClientProtocol string
ClientCapabilities []string
LastSeenAt time.Time
}
type KnownUser struct {
ID string `json:"id"`
Username string `json:"username"`
LastSeen time.Time `json:"lastSeen"`
}
type KnownClient struct {
DeviceName string `json:"deviceName"`
Username string `json:"username"`
Version string `json:"version"`
Protocol string `json:"protocol"`
Capabilities []string `json:"capabilities"`
LastSeen time.Time `json:"lastSeen"`
}
// KnownClients gives the feature console compatibility evidence without exposing
// gateway or Emby credentials. Stale sessions remain useful rollout information.
func (s *Store) KnownClients(ctx context.Context) ([]KnownClient, error) {
rows, err := s.pool.Query(ctx, `
SELECT device_name, username, client_version, client_protocol,
client_capabilities, last_seen_at
FROM sessions ORDER BY last_seen_at DESC LIMIT 100`)
if err != nil {
return nil, fmt.Errorf("store: list known clients: %w", err)
}
defer rows.Close()
clients := []KnownClient{}
for rows.Next() {
var client KnownClient
if err := rows.Scan(&client.DeviceName, &client.Username, &client.Version,
&client.Protocol, &client.Capabilities, &client.LastSeen); err != nil {
return nil, fmt.Errorf("store: scan known client: %w", err)
}
clients = append(clients, client)
}
return clients, rows.Err()
}
// KnownUsers returns one entry per Emby user that has signed in to the gateway.
func (s *Store) KnownUsers(ctx context.Context) ([]KnownUser, error) {
rows, err := s.pool.Query(ctx, `
SELECT DISTINCT ON (emby_user_id) emby_user_id, username, last_seen_at
FROM sessions
ORDER BY emby_user_id, last_seen_at DESC`)
if err != nil {
return nil, fmt.Errorf("store: list known users: %w", err)
}
defer rows.Close()
users := []KnownUser{}
for rows.Next() {
var user KnownUser
if err := rows.Scan(&user.ID, &user.Username, &user.LastSeen); err != nil {
return nil, fmt.Errorf("store: scan known user: %w", err)
}
users = append(users, user)
}
return users, rows.Err()
}
type Store struct {
@@ -112,18 +173,18 @@ func (s *Store) Migrate(ctx context.Context) error {
return nil
}
// CreateSession enforces a user's device allowance under a per-user transaction lock.
// Re-authenticating the same stable device replaces its token and never consumes a slot.
// CreateSession records every signed-in TV without an account-level device cap.
// Re-authenticating the same stable device replaces its token.
// The replaced hash is returned so its Redis entry can be invalidated immediately.
func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int) ([]byte, int, error) {
func (s *Store) CreateSession(ctx context.Context, sess Session) ([]byte, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, 0, fmt.Errorf("store: begin session: %w", err)
return nil, fmt.Errorf("store: begin session: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, sess.EmbyUserID); err != nil {
return nil, 0, fmt.Errorf("store: lock user sessions: %w", err)
return nil, fmt.Errorf("store: lock user sessions: %w", err)
}
var previousHash []byte
@@ -132,26 +193,15 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int)
WHERE emby_user_id = $1 AND device_id = $2`,
sess.EmbyUserID, sess.DeviceID).Scan(&previousHash)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, fmt.Errorf("store: find device session: %w", err)
}
existingDevice := err == nil
var activeClients int
if err := tx.QueryRow(ctx,
`SELECT count(*) FROM sessions WHERE emby_user_id = $1`,
sess.EmbyUserID).Scan(&activeClients); err != nil {
return nil, 0, fmt.Errorf("store: count user sessions: %w", err)
}
if !existingDevice && activeClients >= maxClients {
return nil, activeClients, ErrDeviceLimit
return nil, fmt.Errorf("store: find device session: %w", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO sessions (
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name,
client_version, client_protocol
client_version, client_protocol, client_capabilities
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (emby_user_id, device_id) DO UPDATE SET
token_hash = EXCLUDED.token_hash,
emby_token = EXCLUDED.emby_token,
@@ -160,30 +210,29 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int)
device_name = EXCLUDED.device_name,
client_version = EXCLUDED.client_version,
client_protocol = EXCLUDED.client_protocol,
client_capabilities = EXCLUDED.client_capabilities,
last_seen_at = now()`,
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username,
sess.ServerID, sess.DeviceID, sess.DeviceName, sess.ClientVersion, sess.ClientProtocol)
sess.ServerID, sess.DeviceID, sess.DeviceName, sess.ClientVersion, sess.ClientProtocol,
sess.ClientCapabilities)
if err != nil {
return nil, 0, fmt.Errorf("store: create session: %w", err)
}
if !existingDevice {
activeClients++
return nil, fmt.Errorf("store: create session: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, 0, fmt.Errorf("store: commit session: %w", err)
return nil, fmt.Errorf("store: commit session: %w", err)
}
return previousHash, activeClients, nil
return previousHash, nil
}
func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) {
var sess Session
err := s.pool.QueryRow(ctx, `
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id,
device_name, client_version, client_protocol, last_seen_at
device_name, client_version, client_protocol, client_capabilities, last_seen_at
FROM sessions WHERE token_hash = $1`, hash).
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.LastSeenAt)
&sess.ClientProtocol, &sess.ClientCapabilities, &sess.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return Session{}, ErrNotFound
}
@@ -205,15 +254,16 @@ func (s *Store) Touch(ctx context.Context, hash []byte) error {
func (s *Store) UpdateSessionClientIdentity(
ctx context.Context,
hash []byte,
version, protocol string,
version, protocol string, capabilities []string,
) error {
_, err := s.pool.Exec(ctx, `
UPDATE sessions
SET client_version = CASE WHEN $2 <> '' THEN $2 ELSE client_version END,
client_protocol = CASE WHEN $3 <> '' THEN $3 ELSE client_protocol END,
client_capabilities = CASE WHEN cardinality($4::text[]) > 0 THEN $4 ELSE client_capabilities END,
last_seen_at = now()
WHERE token_hash = $1`,
hash, version, protocol)
hash, version, protocol, capabilities)
return err
}
@@ -222,6 +272,66 @@ func (s *Store) DeleteSession(ctx context.Context, hash []byte) error {
return err
}
// SessionsForUser returns the TVs whose gateway tokens are still active for a user.
func (s *Store) SessionsForUser(ctx context.Context, userID string) ([]Session, error) {
rows, err := s.pool.Query(ctx, `
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id,
device_name, client_version, client_protocol, client_capabilities, last_seen_at
FROM sessions
WHERE emby_user_id = $1
ORDER BY last_seen_at DESC, device_name`, userID)
if err != nil {
return nil, fmt.Errorf("store: list user sessions: %w", err)
}
defer rows.Close()
var sessions []Session
for rows.Next() {
var sess Session
if err := rows.Scan(
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.ClientCapabilities, &sess.LastSeenAt,
); err != nil {
return nil, fmt.Errorf("store: scan user session: %w", err)
}
sessions = append(sessions, sess)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: list user session rows: %w", err)
}
return sessions, nil
}
// DeleteUserDevice revokes one device while scoping the delete to the authenticated user.
func (s *Store) DeleteUserDevice(ctx context.Context, userID, deviceID string) ([]byte, error) {
var tokenHash []byte
err := s.pool.QueryRow(ctx, `
DELETE FROM sessions
WHERE emby_user_id = $1 AND device_id = $2
RETURNING token_hash`, userID, deviceID).Scan(&tokenHash)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("store: delete user device: %w", err)
}
return tokenHash, nil
}
// RenameUserDevice changes only the display name and keeps the session/token intact.
func (s *Store) RenameUserDevice(ctx context.Context, userID, deviceID, deviceName string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE sessions SET device_name = $3
WHERE emby_user_id = $1 AND device_id = $2`, userID, deviceID, deviceName)
if err != nil {
return fmt.Errorf("store: rename user device: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// DeleteIdleSessions retires tokens unused for longer than idle, returning how many went.
func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int64, error) {
tag, err := s.pool.Exec(ctx,
@@ -232,53 +342,3 @@ func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int
}
return tag.RowsAffected(), nil
}
// TrimSessionsToLimit brings data created under an older, more generous policy back
// within the current allowance. The most recently active devices survive.
func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Session, error) {
rows, err := s.pool.Query(ctx, `
WITH ranked AS (
SELECT token_hash,
row_number() OVER (
PARTITION BY emby_user_id
ORDER BY last_seen_at DESC, created_at DESC, token_hash DESC
) AS device_rank
FROM sessions
),
retired AS (
DELETE FROM sessions current
USING ranked
WHERE current.token_hash = ranked.token_hash
AND ranked.device_rank > $1
RETURNING current.token_hash, current.emby_user_id, current.emby_token,
current.username, current.server_id, current.device_id,
current.device_name, current.client_version, current.client_protocol,
current.last_seen_at
)
SELECT token_hash, emby_user_id, emby_token, username, server_id,
device_id, device_name, client_version, client_protocol, last_seen_at
FROM retired`,
maxClients,
)
if err != nil {
return nil, fmt.Errorf("store: trim sessions: %w", err)
}
defer rows.Close()
var retired []Session
for rows.Next() {
var sess Session
if err := rows.Scan(
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
&sess.ClientProtocol, &sess.LastSeenAt,
); err != nil {
return nil, fmt.Errorf("store: scan trimmed session: %w", err)
}
retired = append(retired, sess)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: trim sessions rows: %w", err)
}
return retired, nil
}