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)
}
}