Publish current app and server
This commit is contained in:
+196
-15
@@ -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
@@ -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) =>
|
||||
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||||
}[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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 household’s 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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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, ¤t) != 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)
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user