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)
|
||||
}
|
||||
}
|
||||
@@ -33,9 +33,14 @@ type Policy struct {
|
||||
// current release mandatory for everyone.
|
||||
MinimumVersion string `json:"minimumVersion"`
|
||||
// DownloadURL points at the APK — normally the same file the landing page serves.
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
Notes string `json:"notes"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
// SHA256 and SizeBytes let the TV reject a truncated, stale or substituted download
|
||||
// before handing it to Android's package installer. Blank/zero remain valid for
|
||||
// older policies entered manually through the admin page.
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
Notes string `json:"notes"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Decision is what the client receives.
|
||||
@@ -44,6 +49,8 @@ type Decision struct {
|
||||
Version string `json:"version"`
|
||||
Notes string `json:"notes"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
SizeBytes int64 `json:"sizeBytes,omitempty"`
|
||||
}
|
||||
|
||||
// Decide compares the version a client reported against the policy.
|
||||
@@ -67,6 +74,8 @@ func Decide(policy Policy, clientVersion string) Decision {
|
||||
Version: normalize(policy.LatestVersion),
|
||||
Notes: strings.TrimSpace(policy.Notes),
|
||||
DownloadURL: strings.TrimSpace(policy.DownloadURL),
|
||||
SHA256: strings.ToLower(strings.TrimSpace(policy.SHA256)),
|
||||
SizeBytes: policy.SizeBytes,
|
||||
}
|
||||
|
||||
if minimum := parseVersion(policy.MinimumVersion); len(minimum) > 0 && compare(client, minimum) < 0 {
|
||||
|
||||
@@ -8,6 +8,8 @@ func policy() Policy {
|
||||
LatestVersion: "0.1.54",
|
||||
MinimumVersion: "0.1.50",
|
||||
DownloadURL: "https://nas.example.com/memby/memby-0.1.54.apk",
|
||||
SHA256: "abcdef",
|
||||
SizeBytes: 42,
|
||||
Notes: "Faster home screen",
|
||||
}
|
||||
}
|
||||
@@ -29,6 +31,9 @@ func TestBehindLatestIsOptional(t *testing.T) {
|
||||
if decision.Version != "0.1.54" || decision.DownloadURL == "" {
|
||||
t.Fatalf("decision is missing what the TV needs to act: %+v", decision)
|
||||
}
|
||||
if decision.SHA256 != "abcdef" || decision.SizeBytes != 42 {
|
||||
t.Fatalf("decision dropped release integrity metadata: %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBelowMinimumIsMandatory(t *testing.T) {
|
||||
@@ -100,7 +105,6 @@ func TestVersionParsingIsForgiving(t *testing.T) {
|
||||
if got := Decide(p, "0.1.54-beta").Status; got != StatusNone {
|
||||
t.Fatalf("pre-release suffix should compare as 0.1.54, got %s", got)
|
||||
}
|
||||
|
||||
p.LatestVersion = "0.2"
|
||||
if got := Decide(p, "0.2.0").Status; got != StatusNone {
|
||||
t.Fatalf("0.2.0 should equal 0.2, got %s", got)
|
||||
|
||||
Vendored
+2
-7
@@ -84,14 +84,9 @@ func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID,
|
||||
|
||||
// RecommendationsKey sits in its own `r:` namespace on purpose.
|
||||
//
|
||||
// Recommendations cost several Emby queries to build, so they must survive the cache
|
||||
// wipe that every favourite toggle triggers. Only a genuine change in viewing history
|
||||
// — a finished playback — retires them, via [Cache.InvalidateRecommendations].
|
||||
// Recommendations cost several Emby queries to build, so they survive ordinary user-view
|
||||
// invalidation and expire on their own slow-moving daily cadence.
|
||||
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v3", userID) }
|
||||
|
||||
func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error {
|
||||
return c.Delete(ctx, RecommendationsKey(userID))
|
||||
}
|
||||
|
||||
// SessionKey caches a token→session lookup, keyed by token hash (never the token).
|
||||
func SessionKey(tokenHashHex string) string { return "sess:" + tokenHashHex }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -34,18 +35,21 @@ type Config struct {
|
||||
SessionTTL time.Duration
|
||||
// SessionIdleExpiry retires gateway tokens that go unused for this long.
|
||||
SessionIdleExpiry time.Duration
|
||||
// MaxClientsPerUser is enforced transactionally when a new TV signs in.
|
||||
MaxClientsPerUser int
|
||||
|
||||
// RecommendTTL is how long computed recommendation rows stay warm. Long, because
|
||||
// taste moves slowly and each rebuild costs several Emby queries.
|
||||
RecommendTTL time.Duration
|
||||
// RecommendTimeout bounds a background rebuild, which fans out further than a
|
||||
// normal request and so needs more headroom than UpstreamTimeout.
|
||||
RecommendTimeout time.Duration
|
||||
// RecommendationWeights is an optional JSON overlay on the weighted defaults.
|
||||
RecommendationWeights string
|
||||
|
||||
UpstreamTimeout time.Duration
|
||||
|
||||
// EmbyHealthInterval paces the reachability probe behind the "server not responding"
|
||||
// and "back online" banners. One probe per gateway, not per TV. Zero disables it.
|
||||
EmbyHealthInterval time.Duration
|
||||
|
||||
// AdminToken guards the operator interface. Empty disables /admin entirely, so an
|
||||
// unconfigured deployment cannot leave it exposed.
|
||||
AdminToken string
|
||||
@@ -73,7 +77,7 @@ type Config struct {
|
||||
AnalyticsRetention time.Duration
|
||||
|
||||
// Sonarr is optional. When configured, its local calendar supplies the informational
|
||||
// "Shows airing today" home row. The API key never leaves this server.
|
||||
// Five-day "Shows airing" home row. The API key never leaves this server.
|
||||
SonarrURL string
|
||||
SonarrAPIKey string
|
||||
SonarrTTL time.Duration
|
||||
@@ -81,9 +85,24 @@ type Config struct {
|
||||
|
||||
// SonarrAlertWindow is how long after an episode airs the "aired, coming soon"
|
||||
// banner keeps being offered to clients. Zero turns the banners off without
|
||||
// touching the airing-today row.
|
||||
// touching the five-day schedule row.
|
||||
SonarrAlertWindow time.Duration
|
||||
|
||||
// Radarr is optional. Its calendar supplies the five-day digital movie release row.
|
||||
RadarrURL string
|
||||
RadarrAPIKey string
|
||||
RadarrTTL time.Duration
|
||||
RadarrLocation *time.Location
|
||||
|
||||
// RadarrWebhookToken guards the Radarr "on import" webhook. Empty means the hook
|
||||
// 404s, so an unconfigured deployment cannot be fed alerts by anyone who finds it.
|
||||
RadarrWebhookToken string
|
||||
// RadarrAlertWindow is how long an imported movie keeps being announced to clients.
|
||||
// It is a window rather than a one-shot push because the gateway has no connection
|
||||
// to a TV that is switched off: a set turned on inside the window still gets the
|
||||
// news, and each TV dedupes by alert id. Zero turns the banners off.
|
||||
RadarrAlertWindow time.Duration
|
||||
|
||||
// Tracearr is an optional, read-only source of completion, session-length and
|
||||
// direct-play signals for the per-user For You area. The public API key stays in
|
||||
// the gateway and is never returned to a TV.
|
||||
@@ -94,10 +113,11 @@ type Config struct {
|
||||
// late/out-of-order updates and deletions without needing a source cursor.
|
||||
TracearrSyncInterval time.Duration
|
||||
TracearrFullInterval time.Duration
|
||||
// ForYouMinRebuildAge coalesces bursts of playback/library changes. RefreshInterval
|
||||
// is the acceptable age of a prepared pool before it is refreshed.
|
||||
// Prepared pools rebuild daily at RebuildHour in the configured timezone. The age
|
||||
// settings are safety bounds for manual/background fallback paths.
|
||||
ForYouMinRebuildAge time.Duration
|
||||
ForYouRefreshInterval time.Duration
|
||||
ForYouRebuildHour int
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
@@ -114,9 +134,11 @@ func Load() (Config, error) {
|
||||
ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute),
|
||||
SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute),
|
||||
SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour),
|
||||
MaxClientsPerUser: integer("MEMBY_MAX_CLIENTS_PER_USER", 1),
|
||||
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 2*time.Hour),
|
||||
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 24*time.Hour),
|
||||
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
|
||||
RecommendationWeights: strings.TrimSpace(
|
||||
os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"),
|
||||
),
|
||||
|
||||
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
|
||||
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
|
||||
@@ -131,17 +153,24 @@ func Load() (Config, error) {
|
||||
SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")),
|
||||
AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour),
|
||||
UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second),
|
||||
EmbyHealthInterval: duration("MEMBY_EMBY_HEALTH_INTERVAL", 60*time.Second),
|
||||
SonarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_SONARR_URL")), "/"),
|
||||
SonarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SONARR_API_KEY")),
|
||||
SonarrTTL: duration("MEMBY_SONARR_TTL", 5*time.Minute),
|
||||
SonarrAlertWindow: duration("MEMBY_SONARR_ALERT_WINDOW", 3*time.Hour),
|
||||
RadarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RADARR_URL")), "/"),
|
||||
RadarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_RADARR_API_KEY")),
|
||||
RadarrTTL: duration("MEMBY_RADARR_TTL", 5*time.Minute),
|
||||
RadarrWebhookToken: strings.TrimSpace(os.Getenv("MEMBY_RADARR_WEBHOOK_TOKEN")),
|
||||
RadarrAlertWindow: duration("MEMBY_RADARR_ALERT_WINDOW", 3*time.Hour),
|
||||
TracearrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_URL")), "/"),
|
||||
TracearrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_API_KEY")),
|
||||
TracearrServerID: strings.TrimSpace(os.Getenv("MEMBY_TRACEARR_SERVER_ID")),
|
||||
TracearrSyncInterval: duration("MEMBY_TRACEARR_SYNC_INTERVAL", 5*time.Minute),
|
||||
TracearrFullInterval: duration("MEMBY_TRACEARR_FULL_INTERVAL", 24*time.Hour),
|
||||
ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 10*time.Minute),
|
||||
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 30*time.Minute),
|
||||
ForYouMinRebuildAge: duration("MEMBY_FOR_YOU_MIN_REBUILD_AGE", 24*time.Hour),
|
||||
ForYouRefreshInterval: duration("MEMBY_FOR_YOU_REFRESH_INTERVAL", 24*time.Hour),
|
||||
ForYouRebuildHour: integer("MEMBY_FOR_YOU_REBUILD_HOUR", 4),
|
||||
}
|
||||
|
||||
if c.EmbyURL == "" {
|
||||
@@ -150,9 +179,6 @@ func Load() (Config, error) {
|
||||
if c.DatabaseURL == "" {
|
||||
return c, fmt.Errorf("MEMBY_DATABASE_URL is required")
|
||||
}
|
||||
if c.MaxClientsPerUser < 1 {
|
||||
return c, fmt.Errorf("MEMBY_MAX_CLIENTS_PER_USER must be at least 1")
|
||||
}
|
||||
if c.EmbyPublicURL == "" {
|
||||
c.EmbyPublicURL = c.EmbyURL
|
||||
}
|
||||
@@ -162,14 +188,24 @@ func Load() (Config, error) {
|
||||
if (c.SonarrURL == "") != (c.SonarrAPIKey == "") {
|
||||
return c, fmt.Errorf("MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY must be set together")
|
||||
}
|
||||
if (c.RadarrURL == "") != (c.RadarrAPIKey == "") {
|
||||
return c, fmt.Errorf("MEMBY_RADARR_URL and MEMBY_RADARR_API_KEY must be set together")
|
||||
}
|
||||
if (c.TracearrURL == "") != (c.TracearrAPIKey == "") {
|
||||
return c, fmt.Errorf("MEMBY_TRACEARR_URL and MEMBY_TRACEARR_API_KEY must be set together")
|
||||
}
|
||||
if c.ForYouRebuildHour < 0 || c.ForYouRebuildHour > 23 {
|
||||
return c, fmt.Errorf("MEMBY_FOR_YOU_REBUILD_HOUR must be between 0 and 23")
|
||||
}
|
||||
if c.RecommendationWeights != "" && !json.Valid([]byte(c.RecommendationWeights)) {
|
||||
return c, fmt.Errorf("MEMBY_RECOMMENDATION_WEIGHTS must be valid JSON")
|
||||
}
|
||||
location, err := time.LoadLocation(env("MEMBY_TIMEZONE", "Pacific/Auckland"))
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("MEMBY_TIMEZONE: %w", err)
|
||||
}
|
||||
c.SonarrLocation = location
|
||||
c.RadarrLocation = location
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,9 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDefaultClientAllowanceIsOne(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_MAX_CLIENTS_PER_USER", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.MaxClientsPerUser != 1 {
|
||||
t.Fatalf("MaxClientsPerUser = %d, want 1", cfg.MaxClientsPerUser)
|
||||
}
|
||||
}
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTracearrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
@@ -26,3 +15,51 @@ func TestTracearrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
|
||||
t.Fatal("expected incomplete Tracearr configuration to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadarrURLAndKeyMustBeConfiguredTogether(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_RADARR_URL", "http://radarr")
|
||||
t.Setenv("MEMBY_RADARR_API_KEY", "")
|
||||
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected incomplete Radarr configuration to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForYouDefaultsToDailyOffPeakRebuild(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_FOR_YOU_MIN_REBUILD_AGE", "")
|
||||
t.Setenv("MEMBY_FOR_YOU_REFRESH_INTERVAL", "")
|
||||
t.Setenv("MEMBY_FOR_YOU_REBUILD_HOUR", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.ForYouMinRebuildAge != 24*time.Hour ||
|
||||
cfg.ForYouRefreshInterval != 24*time.Hour ||
|
||||
cfg.ForYouRebuildHour != 4 {
|
||||
t.Fatalf("For You schedule = %v/%v hour %d",
|
||||
cfg.ForYouMinRebuildAge, cfg.ForYouRefreshInterval, cfg.ForYouRebuildHour)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForYouRebuildHourIsValidated(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_FOR_YOU_REBUILD_HOUR", "24")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected invalid rebuild hour to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationWeightsMustBeJSON(t *testing.T) {
|
||||
t.Setenv("MEMBY_EMBY_URL", "http://emby")
|
||||
t.Setenv("MEMBY_DATABASE_URL", "postgres://memby")
|
||||
t.Setenv("MEMBY_RECOMMENDATION_WEIGHTS", "{broken")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("expected invalid recommendation weights to fail")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ type Credentials struct {
|
||||
DeviceName string
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"Id"`
|
||||
ReportedDeviceID string `json:"ReportedDeviceId"`
|
||||
}
|
||||
|
||||
type DevicesResult struct {
|
||||
Items []Device `json:"Items"`
|
||||
}
|
||||
|
||||
type ItemsResult struct {
|
||||
Items []json.RawMessage `json:"Items"`
|
||||
TotalRecordCount int `json:"TotalRecordCount"`
|
||||
@@ -57,10 +66,15 @@ type User struct {
|
||||
|
||||
// Summary is the minimal view of an item the gateway needs for its own logic.
|
||||
type Summary struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
UserData struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
Overview string `json:"Overview"`
|
||||
SeriesName string `json:"SeriesName"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
ParentIndexNumber int `json:"ParentIndexNumber"`
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
UserData struct {
|
||||
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
|
||||
} `json:"UserData"`
|
||||
}
|
||||
@@ -71,10 +85,13 @@ type PlaybackInfo struct {
|
||||
}
|
||||
|
||||
type MediaSourceInfo struct {
|
||||
ID string `json:"Id"`
|
||||
MediaStreams []MediaStream `json:"MediaStreams"`
|
||||
DirectStreamURL string `json:"DirectStreamUrl"`
|
||||
TranscodingURL string `json:"TranscodingUrl"`
|
||||
ID string `json:"Id"`
|
||||
MediaStreams []MediaStream `json:"MediaStreams"`
|
||||
SupportsDirectPlay bool `json:"SupportsDirectPlay"`
|
||||
SupportsDirectStream bool `json:"SupportsDirectStream"`
|
||||
SupportsTranscoding bool `json:"SupportsTranscoding"`
|
||||
DirectStreamURL string `json:"DirectStreamUrl"`
|
||||
TranscodingURL string `json:"TranscodingUrl"`
|
||||
}
|
||||
|
||||
type MediaStream struct {
|
||||
@@ -152,6 +169,38 @@ func (c *Client) Logout(ctx context.Context, cred Credentials) error {
|
||||
return c.do(req, nil)
|
||||
}
|
||||
|
||||
// DeleteDevice removes the persistent Emby device record whose client-supplied ID
|
||||
// matches reportedDeviceID. Logging out only revokes the access token; Emby deliberately
|
||||
// keeps the device in its dashboard until the record itself is deleted.
|
||||
func (c *Client) DeleteDevice(
|
||||
ctx context.Context,
|
||||
cred Credentials,
|
||||
reportedDeviceID string,
|
||||
) error {
|
||||
req, err := c.newRequest(ctx, http.MethodGet, "/Devices", nil, cred, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var devices DevicesResult
|
||||
if err := c.do(req, &devices); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, device := range devices.Items {
|
||||
if device.ReportedDeviceID != reportedDeviceID || device.ID == "" {
|
||||
continue
|
||||
}
|
||||
params := url.Values{"Id": {device.ID}}
|
||||
req, err := c.newRequest(ctx, http.MethodDelete, "/Devices", params, cred, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.do(req, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Users returns the household accounts visible to an administrative/service token.
|
||||
// It is used only by the background For You builder, never on a television request.
|
||||
func (c *Client) Users(ctx context.Context, cred Credentials) ([]User, error) {
|
||||
@@ -225,6 +274,8 @@ func (c *Client) PlaybackInfo(
|
||||
startTicks int64,
|
||||
subtitleStreamIndex *int,
|
||||
currentPlaySessionID string,
|
||||
forceTranscode bool,
|
||||
capabilities PlaybackCapabilities,
|
||||
) (*PlaybackInfo, error) {
|
||||
params := url.Values{
|
||||
"UserId": {cred.UserID},
|
||||
@@ -232,39 +283,10 @@ func (c *Client) PlaybackInfo(
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"Id": itemID, "UserId": cred.UserID, "IsPlayback": true,
|
||||
"StartTimeTicks": startTicks,
|
||||
"DeviceProfile": map[string]any{
|
||||
"Name": "Memby Android TV", "SupportedMediaTypes": "Video",
|
||||
"DirectPlayProfiles": []map[string]string{
|
||||
{
|
||||
"Container": "mkv,mp4,m4v,mov,webm,ts,mpegts,avi",
|
||||
"VideoCodec": "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4",
|
||||
"AudioCodec": "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm",
|
||||
"Type": "Video",
|
||||
},
|
||||
},
|
||||
"TranscodingProfiles": []map[string]string{
|
||||
{
|
||||
"Container": "ts", "VideoCodec": "h264", "AudioCodec": "aac",
|
||||
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
|
||||
},
|
||||
},
|
||||
"SubtitleProfiles": []map[string]string{
|
||||
{"Format": "srt", "Method": "External"},
|
||||
{"Format": "subrip", "Method": "External"},
|
||||
{"Format": "ass", "Method": "External"},
|
||||
{"Format": "ssa", "Method": "External"},
|
||||
{"Format": "vtt", "Method": "External"},
|
||||
{"Format": "webvtt", "Method": "External"},
|
||||
{"Format": "mov_text", "Method": "External"},
|
||||
{"Format": "tx3g", "Method": "External"},
|
||||
{"Format": "pgs", "Method": "Encode"},
|
||||
{"Format": "pgssub", "Method": "Encode"},
|
||||
{"Format": "sup", "Method": "Encode"},
|
||||
{"Format": "vobsub", "Method": "Encode"},
|
||||
{"Format": "dvdsub", "Method": "Encode"},
|
||||
},
|
||||
},
|
||||
"StartTimeTicks": startTicks, "EnableDirectPlay": !forceTranscode,
|
||||
"EnableDirectStream": !forceTranscode, "EnableTranscoding": true,
|
||||
"AllowVideoStreamCopy": true, "AllowAudioStreamCopy": true,
|
||||
"DeviceProfile": androidTVDeviceProfile(capabilities),
|
||||
})
|
||||
var requestBody map[string]any
|
||||
if err == nil {
|
||||
@@ -276,6 +298,10 @@ func (c *Client) PlaybackInfo(
|
||||
if currentPlaySessionID != "" {
|
||||
requestBody["CurrentPlaySessionId"] = currentPlaySessionID
|
||||
}
|
||||
if forceTranscode {
|
||||
profile := requestBody["DeviceProfile"].(map[string]any)
|
||||
profile["DirectPlayProfiles"] = []map[string]string{}
|
||||
}
|
||||
if err == nil {
|
||||
body, err = json.Marshal(requestBody)
|
||||
}
|
||||
@@ -301,6 +327,13 @@ func (c *Client) PlaybackInfo(
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func directPlayVideoCodecs(supportsHEVC bool) string {
|
||||
if supportsHEVC {
|
||||
return "h264,hevc"
|
||||
}
|
||||
return "h264"
|
||||
}
|
||||
|
||||
func (c *Client) ResumeItems(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) {
|
||||
return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items/Resume", params)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package emby
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDirectPlayVideoCodecsIncludeHEVCOnlyForCapableClient(t *testing.T) {
|
||||
if got := directPlayVideoCodecs(false); got != "h264" {
|
||||
t.Fatalf("baseline codecs = %q", got)
|
||||
}
|
||||
if got := directPlayVideoCodecs(true); got != "h264,hevc" {
|
||||
t.Fatalf("HEVC codecs = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAndroidTVProfileConstrainsCodecLevelAndResolution(t *testing.T) {
|
||||
profile := androidTVDeviceProfile(PlaybackCapabilities{
|
||||
H264Profiles: []string{"baseline", "main", "high"},
|
||||
H264Level: 52, H264MaxWidth: 3840, H264MaxHeight: 2160,
|
||||
HEVC: true, HEVCMain: true, HEVCMain10: true,
|
||||
HEVCMainLevel: 153, HEVCMain10Level: 153,
|
||||
HEVCMaxWidth: 3840, HEVCMaxHeight: 2160,
|
||||
})
|
||||
direct := profile["DirectPlayProfiles"].([]map[string]string)
|
||||
if direct[0]["VideoCodec"] != "h264,hevc" {
|
||||
t.Fatalf("direct video codecs = %q", direct[0]["VideoCodec"])
|
||||
}
|
||||
transcode := profile["TranscodingProfiles"].([]map[string]string)
|
||||
if transcode[0]["VideoCodec"] != "h264,hevc" {
|
||||
t.Fatalf("stream-copy codecs = %q", transcode[0]["VideoCodec"])
|
||||
}
|
||||
codecProfiles := profile["CodecProfiles"].([]map[string]any)
|
||||
if len(codecProfiles) < 6 {
|
||||
t.Fatalf("codec profiles = %#v", codecProfiles)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Playback profile generation is adapted from Wholphin's DeviceProfileUtils.kt,
|
||||
// itself derived from Jellyfin Android TV.
|
||||
//
|
||||
// Wholphin: https://github.com/damontecres/Wholphin
|
||||
// Jellyfin Android TV: https://github.com/jellyfin/jellyfin-androidtv
|
||||
//
|
||||
// Modifications Copyright (C) 2026 Memby contributors
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
package emby
|
||||
|
||||
import "strconv"
|
||||
|
||||
// PlaybackCapabilities is the Android decoder evidence captured for one TV session.
|
||||
// Zero values deliberately describe the legacy H.264-safe profile.
|
||||
type PlaybackCapabilities struct {
|
||||
H264Profiles []string
|
||||
H264Level int
|
||||
H264High10Level int
|
||||
H264MaxWidth int
|
||||
H264MaxHeight int
|
||||
HEVC bool
|
||||
HEVCMain bool
|
||||
HEVCMain10 bool
|
||||
HEVCMainLevel int
|
||||
HEVCMain10Level int
|
||||
HEVCMaxWidth int
|
||||
HEVCMaxHeight int
|
||||
HEVCHDR10 bool
|
||||
HEVCHDR10Plus bool
|
||||
HEVCDolbyVision bool
|
||||
}
|
||||
|
||||
func androidTVDeviceProfile(capabilities PlaybackCapabilities) map[string]any {
|
||||
videoCodecs := directPlayVideoCodecs(capabilities.HEVC)
|
||||
return map[string]any{
|
||||
"Name": "Memby Android TV", "SupportedMediaTypes": "Video",
|
||||
"DirectPlayProfiles": []map[string]string{
|
||||
{
|
||||
"Container": "mkv,mp4,m4v,mov,ts,mpegts", "VideoCodec": videoCodecs,
|
||||
"AudioCodec": "aac,mp3", "Type": "Video",
|
||||
},
|
||||
},
|
||||
"TranscodingProfiles": []map[string]string{
|
||||
{
|
||||
// AllowVideoStreamCopy lets Emby keep a supported H.264/HEVC track and
|
||||
// convert only incompatible audio or subtitles into an HLS stream.
|
||||
"Container": "ts", "VideoCodec": videoCodecs, "AudioCodec": "aac",
|
||||
"Protocol": "hls", "Type": "Video", "Context": "Streaming",
|
||||
},
|
||||
},
|
||||
"CodecProfiles": videoCodecProfiles(capabilities),
|
||||
"SubtitleProfiles": androidTVSubtitleProfiles(),
|
||||
}
|
||||
}
|
||||
|
||||
func androidTVSubtitleProfiles() []map[string]string {
|
||||
return []map[string]string{
|
||||
{"Format": "srt", "Method": "External"},
|
||||
{"Format": "subrip", "Method": "External"},
|
||||
{"Format": "ass", "Method": "External"},
|
||||
{"Format": "ssa", "Method": "External"},
|
||||
{"Format": "vtt", "Method": "External"},
|
||||
{"Format": "webvtt", "Method": "External"},
|
||||
{"Format": "mov_text", "Method": "External"},
|
||||
{"Format": "tx3g", "Method": "External"},
|
||||
{"Format": "pgs", "Method": "Encode"},
|
||||
{"Format": "pgssub", "Method": "Encode"},
|
||||
{"Format": "sup", "Method": "Encode"},
|
||||
{"Format": "vobsub", "Method": "Encode"},
|
||||
{"Format": "dvdsub", "Method": "Encode"},
|
||||
}
|
||||
}
|
||||
|
||||
func videoCodecProfiles(capabilities PlaybackCapabilities) []map[string]any {
|
||||
profiles := []map[string]any{}
|
||||
if len(capabilities.H264Profiles) > 0 {
|
||||
profiles = append(profiles, codecProfile("h264",
|
||||
[]map[string]any{profileCondition("EqualsAny", "VideoProfile", joinProfiles(capabilities.H264Profiles))}, nil))
|
||||
}
|
||||
profiles = appendLevelProfile(
|
||||
profiles, "h264", capabilities.H264Level, "baseline|constrained baseline|main|high",
|
||||
)
|
||||
profiles = appendLevelProfile(profiles, "h264", capabilities.H264High10Level, "high 10")
|
||||
profiles = appendResolutionProfile(
|
||||
profiles, "h264", capabilities.H264MaxWidth, capabilities.H264MaxHeight,
|
||||
)
|
||||
if !capabilities.HEVC {
|
||||
return profiles
|
||||
}
|
||||
|
||||
hevcProfiles := []string{}
|
||||
if capabilities.HEVCMain {
|
||||
hevcProfiles = append(hevcProfiles, "main")
|
||||
}
|
||||
if capabilities.HEVCMain10 {
|
||||
hevcProfiles = append(hevcProfiles, "main 10")
|
||||
}
|
||||
if len(hevcProfiles) > 0 {
|
||||
profiles = append(profiles, codecProfile("hevc",
|
||||
[]map[string]any{profileCondition("EqualsAny", "VideoProfile", joinProfiles(hevcProfiles))}, nil))
|
||||
}
|
||||
profiles = appendLevelProfile(profiles, "hevc", capabilities.HEVCMainLevel, "main")
|
||||
profiles = appendLevelProfile(profiles, "hevc", capabilities.HEVCMain10Level, "main 10")
|
||||
profiles = appendResolutionProfile(
|
||||
profiles, "hevc", capabilities.HEVCMaxWidth, capabilities.HEVCMaxHeight,
|
||||
)
|
||||
return profiles
|
||||
}
|
||||
|
||||
func appendLevelProfile(profiles []map[string]any, codec string, level int, applyTo string) []map[string]any {
|
||||
if level <= 0 {
|
||||
return profiles
|
||||
}
|
||||
condition := "Equals"
|
||||
if containsPipe(applyTo) {
|
||||
condition = "EqualsAny"
|
||||
}
|
||||
return append(profiles, codecProfile(codec,
|
||||
[]map[string]any{profileCondition("LessThanEqual", "VideoLevel", strconv.Itoa(level))},
|
||||
[]map[string]any{profileCondition(condition, "VideoProfile", applyTo)}))
|
||||
}
|
||||
|
||||
func appendResolutionProfile(profiles []map[string]any, codec string, width, height int) []map[string]any {
|
||||
if width <= 0 || height <= 0 {
|
||||
return profiles
|
||||
}
|
||||
return append(profiles, codecProfile(codec, []map[string]any{
|
||||
profileCondition("LessThanEqual", "Width", strconv.Itoa(width)),
|
||||
profileCondition("LessThanEqual", "Height", strconv.Itoa(height)),
|
||||
}, nil))
|
||||
}
|
||||
|
||||
func codecProfile(codec string, conditions, applyConditions []map[string]any) map[string]any {
|
||||
profile := map[string]any{
|
||||
"Type": "Video", "Codec": codec, "Conditions": conditions,
|
||||
}
|
||||
if len(applyConditions) > 0 {
|
||||
profile["ApplyConditions"] = applyConditions
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func profileCondition(condition, property, value string) map[string]any {
|
||||
return map[string]any{
|
||||
"Condition": condition, "Property": property, "Value": value, "IsRequired": false,
|
||||
}
|
||||
}
|
||||
|
||||
func joinProfiles(values []string) string {
|
||||
result := ""
|
||||
for _, value := range values {
|
||||
if result != "" {
|
||||
result += "|"
|
||||
}
|
||||
result += value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func containsPipe(value string) bool {
|
||||
for _, char := range value {
|
||||
if char == '|' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -30,6 +30,10 @@ const (
|
||||
unchangedPagesToStop = 2
|
||||
preparedPoolReadLimit = 240
|
||||
preparedRowSize = 20
|
||||
maxPreparedCandidates = 750
|
||||
|
||||
// Increment only when stored eligibility, scoring, or explanation behavior changes.
|
||||
preparedAlgorithmVersion = "2026-07-31.2"
|
||||
)
|
||||
|
||||
type ImportResult struct {
|
||||
@@ -37,6 +41,7 @@ type ImportResult struct {
|
||||
Pages int `json:"pages"`
|
||||
Seen int `json:"seen"`
|
||||
Changed int `json:"changed"`
|
||||
Dirtied int64 `json:"dirtied"`
|
||||
Removed int64 `json:"removed"`
|
||||
Duration time.Duration `json:"-"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
@@ -51,6 +56,8 @@ type Service struct {
|
||||
log *slog.Logger
|
||||
minRebuildAge time.Duration
|
||||
refreshAge time.Duration
|
||||
location *time.Location
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
importRunning bool
|
||||
@@ -74,7 +81,15 @@ func New(
|
||||
return &Service{
|
||||
store: st, tracearr: tracearrClient, engine: engine, log: log,
|
||||
minRebuildAge: minRebuildAge, refreshAge: refreshAge,
|
||||
building: map[string]bool{},
|
||||
location: time.Local, now: time.Now, building: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigureTimeContext sets the household timezone used for day/time viewing habits.
|
||||
func (s *Service) ConfigureTimeContext(location *time.Location) {
|
||||
if location != nil {
|
||||
s.location = location
|
||||
s.engine.Location = location
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +152,7 @@ func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, r
|
||||
}()
|
||||
|
||||
unchangedPages := 0
|
||||
terminalIdentities := map[string]store.RecommendationIdentity{}
|
||||
for pageNumber := 1; ; pageNumber++ {
|
||||
page, err := s.tracearr.Page(ctx, pageNumber, importPageSize)
|
||||
if err != nil {
|
||||
@@ -157,7 +173,7 @@ func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, r
|
||||
ServerID: value.ServerID, SessionID: value.SessionID,
|
||||
})
|
||||
}
|
||||
current, err := s.store.TracearrFingerprints(ctx, keys)
|
||||
current, err := s.store.TracearrSessionSignals(ctx, keys)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -166,10 +182,19 @@ func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, r
|
||||
key := store.TracearrSessionKey{
|
||||
ServerID: session.ServerID, SessionID: session.SessionID,
|
||||
}
|
||||
if !bytes.Equal(current[key], session.SourceFingerprint) {
|
||||
previous, exists := current[key]
|
||||
if !bytes.Equal(previous.Fingerprint, session.SourceFingerprint) {
|
||||
pageChanged = true
|
||||
result.Changed++
|
||||
}
|
||||
if tracearrSessionTerminal(session) && (!exists || !previous.Terminal) {
|
||||
identity := store.RecommendationIdentity{
|
||||
TracearrUserID: session.UserID,
|
||||
Username: session.Username,
|
||||
}
|
||||
terminalIdentities[identity.TracearrUserID+"|"+
|
||||
strings.ToLower(identity.Username)] = identity
|
||||
}
|
||||
}
|
||||
if err := s.store.UpsertTracearrSessions(ctx, imported, started); err != nil {
|
||||
return result, err
|
||||
@@ -201,40 +226,45 @@ func (s *Service) Import(ctx context.Context, full bool) (result ImportResult, r
|
||||
}
|
||||
result.Removed = removed
|
||||
}
|
||||
if result.Changed > 0 || result.Removed > 0 {
|
||||
if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
|
||||
return result, err
|
||||
}
|
||||
users, err := s.store.ActiveRecommendationUsers(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if err := s.store.MarkForYouDirty(ctx, user.EmbyUserID, user.Username); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
affected := make([]store.RecommendationIdentity, 0, len(terminalIdentities))
|
||||
for _, identity := range terminalIdentities {
|
||||
affected = append(affected, identity)
|
||||
}
|
||||
dirtied, err := s.store.MarkForYouProfilesDirtyByTracearrIdentity(ctx, affected)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Dirtied = dirtied
|
||||
s.log.Info("Tracearr import finished",
|
||||
"kind", result.Kind, "pages", result.Pages, "seen", result.Seen,
|
||||
"changed", result.Changed, "removed", result.Removed)
|
||||
"changed", result.Changed, "dirtied", result.Dirtied, "removed", result.Removed)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func tracearrSessionTerminal(session store.TracearrSession) bool {
|
||||
state := strings.ToLower(strings.TrimSpace(session.State))
|
||||
return session.Watched || session.StoppedAt != nil ||
|
||||
state == "stopped" || state == "completed" || state == "complete" || state == "ended"
|
||||
}
|
||||
|
||||
func (s *Service) Rebuild(ctx context.Context, sess store.Session, force bool) error {
|
||||
if !s.beginBuild(sess.EmbyUserID) {
|
||||
return nil
|
||||
}
|
||||
defer s.endBuild(sess.EmbyUserID)
|
||||
|
||||
_, poolBuiltAt, dirtySince, err := s.store.ForYouProfileTimes(ctx, sess.EmbyUserID)
|
||||
_, poolBuiltAt, dirtySince, algorithmVersion, err :=
|
||||
s.store.ForYouProfileTimes(ctx, sess.EmbyUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !force && poolBuiltAt != nil && time.Since(*poolBuiltAt) < s.minRebuildAge {
|
||||
algorithmChanged := algorithmVersion != preparedAlgorithmVersion
|
||||
if !force && !algorithmChanged && poolBuiltAt != nil &&
|
||||
time.Since(*poolBuiltAt) < s.minRebuildAge {
|
||||
return nil
|
||||
}
|
||||
if !force && dirtySince == nil && poolBuiltAt != nil && time.Since(*poolBuiltAt) < s.refreshAge {
|
||||
if !force && !algorithmChanged && dirtySince == nil && poolBuiltAt != nil &&
|
||||
time.Since(*poolBuiltAt) < s.refreshAge {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -294,11 +324,69 @@ func (s *Service) PreparedRows(
|
||||
if len(items) == 0 {
|
||||
return nil, false, builtAt != nil && time.Since(*builtAt) >= s.refreshAge, nil
|
||||
}
|
||||
items = rankPreparedForTime(items, s.now(), s.location)
|
||||
rows := buildPreparedRows(items, minutes, s.engine.MinRowItems)
|
||||
stale := builtAt == nil || time.Since(*builtAt) >= s.refreshAge
|
||||
return rows, len(rows) > 0, stale, nil
|
||||
}
|
||||
|
||||
func rankPreparedForTime(
|
||||
items []store.PreparedForYouItem,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
) []store.PreparedForYouItem {
|
||||
if len(items) < 2 || len(items[0].ContextAffinity) == 0 {
|
||||
return items
|
||||
}
|
||||
var profile recommend.ContextAffinityProfile
|
||||
if err := json.Unmarshal(items[0].ContextAffinity, &profile); err != nil ||
|
||||
len(profile.Slots) == 0 {
|
||||
return items
|
||||
}
|
||||
type contextualItem struct {
|
||||
item store.PreparedForYouItem
|
||||
score float64
|
||||
contextRaw float64
|
||||
confidence float64
|
||||
}
|
||||
ranked := make([]contextualItem, 0, len(items))
|
||||
var maxContext float64
|
||||
for _, value := range items {
|
||||
decoded := recommend.Decode([]json.RawMessage{value.Payload})
|
||||
raw, confidence := 0.0, 0.0
|
||||
if len(decoded) == 1 && value.ReasonKind != "pick-up" {
|
||||
raw, confidence = profile.Score(decoded[0], now, location)
|
||||
if raw > maxContext {
|
||||
maxContext = raw
|
||||
}
|
||||
}
|
||||
ranked = append(ranked, contextualItem{
|
||||
item: value, score: value.BaseScore,
|
||||
contextRaw: raw, confidence: confidence,
|
||||
})
|
||||
}
|
||||
if maxContext == 0 {
|
||||
return items
|
||||
}
|
||||
for i := range ranked {
|
||||
ranked[i].score += 1.5 * ranked[i].confidence * ranked[i].contextRaw / maxContext
|
||||
if ranked[i].contextRaw > 0 && ranked[i].confidence >= 0.4 {
|
||||
ranked[i].item.RecommendationReason += " · fits what you watch around this time"
|
||||
}
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].score != ranked[j].score {
|
||||
return ranked[i].score > ranked[j].score
|
||||
}
|
||||
return ranked[i].item.BaseRank < ranked[j].item.BaseRank
|
||||
})
|
||||
out := make([]store.PreparedForYouItem, 0, len(ranked))
|
||||
for _, entry := range ranked {
|
||||
out = append(out, entry.item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) MarkDirty(ctx context.Context, sess store.Session) {
|
||||
if err := s.store.MarkForYouDirty(ctx, sess.EmbyUserID, sess.Username); err != nil {
|
||||
s.log.Warn("could not mark For You dirty", "user", sess.EmbyUserID, "error", err)
|
||||
@@ -328,6 +416,28 @@ func (s *Service) RebuildAll(ctx context.Context, force bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RebuildOutdated refreshes only profiles produced by an older algorithm. This makes
|
||||
// startup migrations deterministic without rebuilding every fresh profile on every boot.
|
||||
func (s *Service) RebuildOutdated(ctx context.Context) error {
|
||||
users, err := s.recommendationUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
_, _, _, version, stateErr := s.store.ForYouProfileTimes(ctx, user.EmbyUserID)
|
||||
if stateErr != nil {
|
||||
return stateErr
|
||||
}
|
||||
if version == preparedAlgorithmVersion {
|
||||
continue
|
||||
}
|
||||
if err := s.Rebuild(ctx, user, true); err != nil {
|
||||
s.log.Warn("outdated For You rebuild failed", "user", user.EmbyUserID, "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkAllDirty(ctx context.Context) {
|
||||
if err := s.store.MarkAllForYouProfilesDirty(ctx); err != nil {
|
||||
s.log.Warn("could not mark For You profiles dirty", "error", err)
|
||||
@@ -427,6 +537,15 @@ func buildPreparedRows(
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
eligibleItems := make([]store.PreparedForYouItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.ReasonKind == "pick-up" &&
|
||||
!strings.Contains(item.RecommendationReason, "season 1") {
|
||||
continue
|
||||
}
|
||||
eligibleItems = append(eligibleItems, item)
|
||||
}
|
||||
items = eligibleItems
|
||||
used := map[string]bool{}
|
||||
rows := make([]recommend.Row, 0, 6)
|
||||
appendRowWithMinimum := func(
|
||||
@@ -465,13 +584,16 @@ func buildPreparedRows(
|
||||
|
||||
pickups := make([]store.PreparedForYouItem, 0, preparedRowSize)
|
||||
for _, item := range items {
|
||||
if item.ReasonKind == "pick-up" {
|
||||
// The reason guard also keeps an already-prepared pool from an older server
|
||||
// version from briefly promoting later-season lapses after deployment.
|
||||
if item.ReasonKind == "pick-up" &&
|
||||
strings.Contains(item.RecommendationReason, "season 1") {
|
||||
pickups = append(pickups, item)
|
||||
}
|
||||
}
|
||||
// A pickup is valuable even when only one genuinely abandoned, unfinished series
|
||||
// qualifies. Unlike generic recommendations, padding this shelf would make it lie.
|
||||
appendRowWithMinimum("for-you:pick-up", "Pick these up again", pickups, 1)
|
||||
appendRowWithMinimum("for-you:pick-up", "Pick this show up again", pickups, 1)
|
||||
|
||||
topTitle := "Top picks for you"
|
||||
if minutes > 0 {
|
||||
@@ -542,6 +664,33 @@ func buildPreparedRows(
|
||||
return strings.TrimSpace(item.ReasonGenre) != ""
|
||||
},
|
||||
)
|
||||
beforeBed := make([]store.PreparedForYouItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.ReasonKind != "pick-up" && item.RuntimeMinutes >= 15 &&
|
||||
item.RuntimeMinutes <= 50 {
|
||||
decoded := recommend.Decode([]json.RawMessage{item.Payload})
|
||||
if len(decoded) == 1 && strings.EqualFold(decoded[0].Type, "Series") {
|
||||
beforeBed = append(beforeBed, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
appendRow(
|
||||
"for-you:one-episode-before-bed",
|
||||
"One episode before bed",
|
||||
beforeBed,
|
||||
)
|
||||
|
||||
// The prepared pool contains only unseen candidates. Taking the remaining strong
|
||||
// matches produces a genuine library-discovery shelf; request-time impression
|
||||
// fatigue then rotates repeatedly ignored posters out of its leading positions.
|
||||
hidden := make([]store.PreparedForYouItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !used[item.ItemID] && item.ReasonKind != "pick-up" {
|
||||
hidden = append(hidden, item)
|
||||
}
|
||||
}
|
||||
appendRow("for-you:hidden", "Hidden in your library", hidden)
|
||||
|
||||
compatible := make([]store.PreparedForYouItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if !used[item.ItemID] && item.CompatibilityScore > 0.2 {
|
||||
@@ -569,48 +718,62 @@ func rowKey(value string) string {
|
||||
|
||||
func (s *Service) Schedule(
|
||||
ctx context.Context,
|
||||
importEvery, fullEvery, refreshEvery time.Duration,
|
||||
importEvery, fullEvery time.Duration,
|
||||
rebuildHour int,
|
||||
) {
|
||||
if importEvery <= 0 {
|
||||
var importC <-chan time.Time
|
||||
var importTicker *time.Ticker
|
||||
if importEvery > 0 {
|
||||
importTicker = time.NewTicker(importEvery)
|
||||
importC = importTicker.C
|
||||
defer importTicker.Stop()
|
||||
} else {
|
||||
s.log.Info("Tracearr auto-import disabled")
|
||||
return
|
||||
}
|
||||
importTicker := time.NewTicker(importEvery)
|
||||
defer importTicker.Stop()
|
||||
var fullC, refreshC <-chan time.Time
|
||||
var fullTicker, refreshTicker *time.Ticker
|
||||
var fullC <-chan time.Time
|
||||
var fullTicker *time.Ticker
|
||||
if fullEvery > 0 {
|
||||
fullTicker = time.NewTicker(fullEvery)
|
||||
fullC = fullTicker.C
|
||||
defer fullTicker.Stop()
|
||||
}
|
||||
if refreshEvery > 0 {
|
||||
refreshTicker = time.NewTicker(refreshEvery)
|
||||
refreshC = refreshTicker.C
|
||||
defer refreshTicker.Stop()
|
||||
}
|
||||
nextRebuild := nextDailyRebuild(time.Now(), s.location, rebuildHour)
|
||||
rebuildTimer := time.NewTimer(time.Until(nextRebuild))
|
||||
defer rebuildTimer.Stop()
|
||||
s.log.Info("For You daily rebuild scheduled", "next", nextRebuild)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-importTicker.C:
|
||||
case <-importC:
|
||||
if _, err := s.Import(ctx, false); err != nil {
|
||||
s.log.Warn("scheduled Tracearr import failed", "error", err)
|
||||
} else {
|
||||
_ = s.RebuildAll(ctx, false)
|
||||
}
|
||||
case <-fullC:
|
||||
if _, err := s.Import(ctx, true); err != nil {
|
||||
s.log.Warn("scheduled full Tracearr import failed", "error", err)
|
||||
} else {
|
||||
_ = s.RebuildAll(ctx, false)
|
||||
}
|
||||
case <-refreshC:
|
||||
_ = s.RebuildAll(ctx, false)
|
||||
case <-rebuildTimer.C:
|
||||
if err := s.RebuildAll(ctx, true); err != nil {
|
||||
s.log.Warn("scheduled daily For You rebuild failed", "error", err)
|
||||
}
|
||||
rebuildTimer.Reset(time.Until(nextDailyRebuild(time.Now(), s.location, rebuildHour)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nextDailyRebuild(now time.Time, location *time.Location, hour int) time.Time {
|
||||
if location == nil {
|
||||
location = time.UTC
|
||||
}
|
||||
local := now.In(location)
|
||||
next := time.Date(local.Year(), local.Month(), local.Day(), hour, 0, 0, 0, location)
|
||||
if !next.After(local) {
|
||||
next = next.AddDate(0, 0, 1)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func (s *Service) beginBuild(userID string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -706,6 +869,14 @@ func storedResult(
|
||||
if err != nil {
|
||||
return store.RecommendationProfile{}, nil, err
|
||||
}
|
||||
contextAffinity, err := json.Marshal(result.Profile.ContextAffinity)
|
||||
if err != nil {
|
||||
return store.RecommendationProfile{}, nil, err
|
||||
}
|
||||
weightedProfile, err := json.Marshal(result.Profile.Weighted)
|
||||
if err != nil {
|
||||
return store.RecommendationProfile{}, nil, err
|
||||
}
|
||||
profile := store.RecommendationProfile{
|
||||
EmbyUserID: userID, TracearrUserID: result.Profile.TracearrUserID,
|
||||
TracearrUsername: result.Profile.TracearrUsername,
|
||||
@@ -713,10 +884,14 @@ func storedResult(
|
||||
MeanCompletionRatio: result.Profile.MeanCompletionRatio,
|
||||
TypicalSessionMinutes: result.Profile.TypicalSessionMinutes,
|
||||
GenreAffinity: genre, TitleAffinity: title, StudioAffinity: studio,
|
||||
CodecOutcomes: codecs, SignalsThrough: result.Profile.SignalsThrough, BuiltAt: builtAt,
|
||||
ContextAffinity: contextAffinity, CodecOutcomes: codecs,
|
||||
WeightedProfile: weightedProfile,
|
||||
AlgorithmVersion: preparedAlgorithmVersion,
|
||||
SignalsThrough: result.Profile.SignalsThrough, BuiltAt: builtAt,
|
||||
}
|
||||
candidates := make([]store.ForYouCandidate, 0, len(result.Candidates))
|
||||
for _, value := range result.Candidates {
|
||||
candidateCount := min(len(result.Candidates), maxPreparedCandidates)
|
||||
candidates := make([]store.ForYouCandidate, 0, candidateCount)
|
||||
for _, value := range result.Candidates[:candidateCount] {
|
||||
candidates = append(candidates, store.ForYouCandidate{
|
||||
ItemID: value.ItemID, BaseRank: value.BaseRank, BaseScore: value.BaseScore,
|
||||
RuntimeMinutes: value.RuntimeMinutes, AffinityScore: value.AffinityScore,
|
||||
|
||||
@@ -4,11 +4,58 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"github.com/ponzischeme89/memby/server/internal/store"
|
||||
"github.com/ponzischeme89/memby/server/internal/tracearr"
|
||||
)
|
||||
|
||||
func TestRankPreparedForTimeMovesTypicalPosterAheadWithinRelevantPool(t *testing.T) {
|
||||
location := time.UTC
|
||||
contextProfile := recommend.NewContextAffinityProfile()
|
||||
comedyHistory := recommend.Item{Genres: []string{"Comedy"}}
|
||||
for i := 0; i < 5; i++ {
|
||||
contextProfile.Add(
|
||||
comedyHistory,
|
||||
time.Date(2026, 6, 29+i*7, 20, 0, 0, 0, location),
|
||||
1,
|
||||
i,
|
||||
location,
|
||||
)
|
||||
}
|
||||
rawContext, err := json.Marshal(contextProfile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := []store.PreparedForYouItem{
|
||||
{
|
||||
ItemID: "drama", BaseRank: 1, BaseScore: 5,
|
||||
Payload: json.RawMessage(`{"Id":"drama","Genres":["Drama"]}`),
|
||||
ContextAffinity: rawContext,
|
||||
},
|
||||
{
|
||||
ItemID: "comedy", BaseRank: 2, BaseScore: 4.8,
|
||||
Payload: json.RawMessage(`{"Id":"comedy","Genres":["Comedy"]}`),
|
||||
RecommendationReason: "Matches your viewing",
|
||||
ContextAffinity: rawContext,
|
||||
},
|
||||
}
|
||||
|
||||
ranked := rankPreparedForTime(
|
||||
items,
|
||||
time.Date(2026, 7, 27, 20, 0, 0, 0, location),
|
||||
location,
|
||||
)
|
||||
if ranked[0].ItemID != "comedy" {
|
||||
t.Fatalf("first contextual poster = %q, want comedy", ranked[0].ItemID)
|
||||
}
|
||||
if ranked[0].RecommendationReason !=
|
||||
"Matches your viewing · fits what you watch around this time" {
|
||||
t.Fatalf("context reason = %q", ranked[0].RecommendationReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportedSessionUsesStableKeyAndRecommendationFingerprint(t *testing.T) {
|
||||
session := tracearr.Session{
|
||||
ID: "session-1", MediaType: "movie", MediaTitle: "Arrival",
|
||||
@@ -35,6 +82,52 @@ func TestImportedSessionUsesStableKeyAndRecommendationFingerprint(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTracearrSessionTerminalRequiresCompletedState(t *testing.T) {
|
||||
active := store.TracearrSession{State: "playing"}
|
||||
if tracearrSessionTerminal(active) {
|
||||
t.Fatal("active session was terminal")
|
||||
}
|
||||
stopped := active
|
||||
stopped.State = "stopped"
|
||||
if !tracearrSessionTerminal(stopped) {
|
||||
t.Fatal("stopped session was not terminal")
|
||||
}
|
||||
watched := active
|
||||
watched.Watched = true
|
||||
if !tracearrSessionTerminal(watched) {
|
||||
t.Fatal("watched session was not terminal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextDailyRebuildUsesConfiguredLocalHour(t *testing.T) {
|
||||
location := time.FixedZone("NZST", 12*60*60)
|
||||
now := time.Date(2026, 7, 31, 5, 30, 0, 0, location)
|
||||
next := nextDailyRebuild(now, location, 4)
|
||||
want := time.Date(2026, 8, 1, 4, 0, 0, 0, location)
|
||||
if !next.Equal(want) {
|
||||
t.Fatalf("next rebuild = %v, want %v", next, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoredResultCapsCandidatePoolAndPersistsAlgorithmVersion(t *testing.T) {
|
||||
result := recommend.PreparedResult{
|
||||
Candidates: make([]recommend.PreparedCandidate, maxPreparedCandidates+50),
|
||||
}
|
||||
for i := range result.Candidates {
|
||||
result.Candidates[i].ItemID = itoaForTest(i + 1)
|
||||
}
|
||||
profile, candidates, err := storedResult("user", time.Now(), result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(candidates) != maxPreparedCandidates {
|
||||
t.Fatalf("candidate count = %d, want %d", len(candidates), maxPreparedCandidates)
|
||||
}
|
||||
if profile.AlgorithmVersion != preparedAlgorithmVersion {
|
||||
t.Fatalf("algorithm version = %q", profile.AlgorithmVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T) {
|
||||
items := make([]store.PreparedForYouItem, 0, 120)
|
||||
for i := 0; i < 120; i++ {
|
||||
@@ -84,15 +177,23 @@ func TestBuildPreparedRowsUsesMultipleSourcesAndDeduplicatesTitles(t *testing.T)
|
||||
}
|
||||
|
||||
func TestBuildPreparedRowsKeepsASingleGenuinePickup(t *testing.T) {
|
||||
rows := buildPreparedRows([]store.PreparedForYouItem{{
|
||||
ItemID: "show-1", BaseRank: 1,
|
||||
Payload: json.RawMessage(`{"Id":"show-1"}`),
|
||||
ReasonKind: "pick-up",
|
||||
RecommendationReason: "You left this in season 1 · pick it up again",
|
||||
}}, 0, 4)
|
||||
rows := buildPreparedRows([]store.PreparedForYouItem{
|
||||
{
|
||||
ItemID: "show-1", BaseRank: 1,
|
||||
Payload: json.RawMessage(`{"Id":"show-1"}`),
|
||||
ReasonKind: "pick-up",
|
||||
RecommendationReason: "You left this in season 1 · pick it up again",
|
||||
},
|
||||
{
|
||||
ItemID: "show-2", BaseRank: 2,
|
||||
Payload: json.RawMessage(`{"Id":"show-2"}`),
|
||||
ReasonKind: "pick-up",
|
||||
RecommendationReason: "You made it through season 2 · season 3 is waiting",
|
||||
},
|
||||
}, 0, 4)
|
||||
|
||||
if len(rows) != 1 || rows[0].ID != "for-you:pick-up" ||
|
||||
rows[0].Title != "Pick these up again" || len(rows[0].Items) != 1 {
|
||||
rows[0].Title != "Pick this show up again" || len(rows[0].Items) != 1 {
|
||||
t.Fatalf("pickup rows = %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ const (
|
||||
// syncFields is everything the gateway serves or filters on. Images are requested as
|
||||
// tags only — the artwork itself is proxied on demand.
|
||||
syncFields = "Genres,Studios,Overview,Taglines,ProductionYear,CommunityRating,OfficialRating," +
|
||||
"RunTimeTicks,SeriesName,PrimaryImageAspectRatio,DateCreated,MediaStreams"
|
||||
"RunTimeTicks,SeriesName,PrimaryImageAspectRatio,DateCreated,PremiereDate,People," +
|
||||
"CollectionName,MediaStreams,RecursiveItemCount"
|
||||
|
||||
syncImageTypes = "Backdrop,Primary,Logo,Thumb"
|
||||
syncItemTypes = "Movie,Series,Episode"
|
||||
@@ -58,7 +59,7 @@ type Syncer struct {
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
afterSync func()
|
||||
afterSync func(Result)
|
||||
}
|
||||
|
||||
func NewSyncer(embyClient *emby.Client, st *store.Store, serviceCred emby.Credentials, log *slog.Logger) *Syncer {
|
||||
@@ -66,7 +67,7 @@ func NewSyncer(embyClient *emby.Client, st *store.Store, serviceCred emby.Creden
|
||||
}
|
||||
|
||||
// SetAfterSync installs the inexpensive invalidation callback used by derived data.
|
||||
func (s *Syncer) SetAfterSync(callback func()) {
|
||||
func (s *Syncer) SetAfterSync(callback func(Result)) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.afterSync = callback
|
||||
@@ -77,6 +78,7 @@ type Result struct {
|
||||
Kind string `json:"kind"`
|
||||
Seen int `json:"seen"`
|
||||
Upserted int `json:"upserted"`
|
||||
Changed int `json:"changed"`
|
||||
Removed int `json:"removed"`
|
||||
Duration time.Duration `json:"-"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
@@ -156,13 +158,13 @@ func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error)
|
||||
}
|
||||
s.log.Info("library sync finished",
|
||||
"kind", kind, "trigger", trigger, "seen", result.Seen,
|
||||
"upserted", result.Upserted, "removed", result.Removed,
|
||||
"upserted", result.Upserted, "changed", result.Changed, "removed", result.Removed,
|
||||
"duration", result.Duration.Round(time.Millisecond))
|
||||
s.mu.Lock()
|
||||
afterSync := s.afterSync
|
||||
s.mu.Unlock()
|
||||
if afterSync != nil {
|
||||
afterSync()
|
||||
afterSync(result)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -213,17 +215,19 @@ func (s *Syncer) run(
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
written, err := s.store.UpsertLibraryItems(ctx, items, syncedAt)
|
||||
changed, err := s.store.UpsertLibraryItems(ctx, items, syncedAt)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
result.Seen += len(page.Items)
|
||||
result.Upserted += int(written)
|
||||
result.Upserted += len(items)
|
||||
result.Changed += int(changed)
|
||||
s.log.Info("library sync progress",
|
||||
"kind", kind,
|
||||
"seen", result.Seen,
|
||||
"upserted", result.Upserted,
|
||||
"changed", result.Changed,
|
||||
)
|
||||
|
||||
if len(page.Items) < pageSize {
|
||||
|
||||
@@ -56,15 +56,25 @@ type EventPage struct {
|
||||
}
|
||||
|
||||
// Buffer is a bounded, concurrency-safe ring of recent structured log records.
|
||||
// Bounding is important: a broken TV can generate traffic indefinitely, while 20,000
|
||||
// records is still enough context for an operator to inspect a sustained incident.
|
||||
// Bounding is important: a broken TV can generate traffic indefinitely. The configured
|
||||
// ring retains enough context for an operator without growing or shifting on every write.
|
||||
type Buffer struct {
|
||||
mu sync.RWMutex
|
||||
capacity int
|
||||
events []Event
|
||||
start int
|
||||
next atomic.Int64
|
||||
}
|
||||
|
||||
// ParseCapacity returns a non-negative log buffer capacity from configuration.
|
||||
func ParseCapacity(value string, fallback int) int {
|
||||
capacity, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || capacity < 0 {
|
||||
return fallback
|
||||
}
|
||||
return capacity
|
||||
}
|
||||
|
||||
// NewBuffered writes normal text logs and mirrors accepted records into a ring buffer.
|
||||
func NewBuffered(w io.Writer, level slog.Leveler, capacity int) (*slog.Logger, *Buffer) {
|
||||
options := &slog.HandlerOptions{
|
||||
@@ -158,8 +168,8 @@ func (b *Buffer) append(event Event) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if len(b.events) == b.capacity {
|
||||
copy(b.events, b.events[1:])
|
||||
b.events[len(b.events)-1] = event
|
||||
b.events[b.start] = event
|
||||
b.start = (b.start + 1) % b.capacity
|
||||
return
|
||||
}
|
||||
b.events = append(b.events, event)
|
||||
@@ -182,20 +192,23 @@ func (b *Buffer) Events(after int64, limit int) EventPage {
|
||||
if len(b.events) == 0 {
|
||||
return page
|
||||
}
|
||||
page.Oldest = b.events[0].Sequence
|
||||
page.Oldest = b.events[b.start].Sequence
|
||||
if after < page.Oldest-1 {
|
||||
page.Dropped = page.Oldest - after - 1
|
||||
after = page.Oldest - 1
|
||||
}
|
||||
start := len(b.events)
|
||||
for i := range b.events {
|
||||
if b.events[i].Sequence > after {
|
||||
event := b.events[(b.start+i)%len(b.events)]
|
||||
if event.Sequence > after {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
end := min(start+limit, len(b.events))
|
||||
page.Events = append(page.Events, b.events[start:end]...)
|
||||
for i := start; i < end; i++ {
|
||||
page.Events = append(page.Events, b.events[(b.start+i)%len(b.events)])
|
||||
}
|
||||
if len(page.Events) > 0 {
|
||||
page.Next = page.Events[len(page.Events)-1].Sequence
|
||||
}
|
||||
|
||||
@@ -56,3 +56,12 @@ func TestParseLevel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCapacity(t *testing.T) {
|
||||
if got := ParseCapacity("1000", 5); got != 1000 {
|
||||
t.Fatalf("capacity = %d", got)
|
||||
}
|
||||
if got := ParseCapacity("-1", 5); got != 5 {
|
||||
t.Fatalf("negative capacity = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Package radarr provides the small read-only slice of Radarr used by the home screen.
|
||||
package radarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
type Image struct {
|
||||
CoverType string `json:"coverType"`
|
||||
URL string `json:"url"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
}
|
||||
|
||||
type MovieFile struct {
|
||||
DateAdded *time.Time `json:"dateAdded"`
|
||||
}
|
||||
|
||||
type Movie struct {
|
||||
ID int `json:"id"`
|
||||
TMDBID int `json:"tmdbId"`
|
||||
Title string `json:"title"`
|
||||
TitleSlug string `json:"titleSlug"`
|
||||
Overview string `json:"overview"`
|
||||
Year int `json:"year"`
|
||||
Runtime int `json:"runtime"`
|
||||
Genres []string `json:"genres"`
|
||||
Images []Image `json:"images"`
|
||||
DigitalRelease *time.Time `json:"digitalRelease"`
|
||||
PhysicalRelease *time.Time `json:"physicalRelease"`
|
||||
InCinemas *time.Time `json:"inCinemas"`
|
||||
HasFile bool `json:"hasFile"`
|
||||
Monitored bool `json:"monitored"`
|
||||
MovieFile *MovieFile `json:"movieFile"`
|
||||
RootFolderPath string `json:"rootFolderPath,omitempty"`
|
||||
QualityProfileID int `json:"qualityProfileId,omitempty"`
|
||||
MinimumAvailability string `json:"minimumAvailability,omitempty"`
|
||||
}
|
||||
|
||||
type RootFolder struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type QualityProfile struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("radarr: status %d: %s", e.StatusCode, e.Body)
|
||||
}
|
||||
|
||||
func New(baseURL, apiKey string, timeout time.Duration) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
apiKey: apiKey,
|
||||
http: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 20,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Calendar returns movies whose Radarr calendar dates intersect [start, end). Memby
|
||||
// selects actual digital releases and its cinema-date fallback after fetching the data.
|
||||
func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Movie, error) {
|
||||
params := url.Values{
|
||||
"start": {start.UTC().Format(time.RFC3339Nano)},
|
||||
"end": {end.UTC().Format(time.RFC3339Nano)},
|
||||
"unmonitored": {"true"},
|
||||
}
|
||||
req, err := c.request(ctx, "/api/v3/calendar", params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var movies []Movie
|
||||
if err := c.do(req, &movies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) {
|
||||
req, err := c.request(ctx, "/api/v3/movie/lookup", url.Values{"term": {term}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var movies []Movie
|
||||
if err := c.do(req, &movies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
// AddUnmonitored adds a title without starting a search or monitoring future releases.
|
||||
func (c *Client) AddUnmonitored(ctx context.Context, movie Movie) (Movie, error) {
|
||||
var roots []RootFolder
|
||||
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
|
||||
return Movie{}, err
|
||||
}
|
||||
var profiles []QualityProfile
|
||||
if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil {
|
||||
return Movie{}, err
|
||||
}
|
||||
if len(roots) == 0 || len(profiles) == 0 {
|
||||
return Movie{}, fmt.Errorf("radarr: no root folder or quality profile configured")
|
||||
}
|
||||
movie.ID = 0
|
||||
movie.RootFolderPath = roots[0].Path
|
||||
movie.QualityProfileID = profiles[0].ID
|
||||
movie.Monitored = false
|
||||
body := struct {
|
||||
Movie
|
||||
AddOptions map[string]bool `json:"addOptions"`
|
||||
}{Movie: movie, AddOptions: map[string]bool{"searchForMovie": false}}
|
||||
var added Movie
|
||||
if err := c.post(ctx, "/api/v3/movie", body, &added); err != nil {
|
||||
return Movie{}, err
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// MediaCover fetches a movie poster or fanart without exposing the Radarr API key.
|
||||
func (c *Client) MediaCover(ctx context.Context, movieID int, coverType string) (*http.Response, error) {
|
||||
if movieID <= 0 || (coverType != "poster" && coverType != "fanart") {
|
||||
return nil, fmt.Errorf("radarr: invalid media cover")
|
||||
}
|
||||
path := "/MediaCover/" + strconv.Itoa(movieID) + "/" + coverType + ".jpg"
|
||||
req, err := c.request(ctx, path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "image/*")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("radarr: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return nil, &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) request(ctx context.Context, path string, params url.Values) (*http.Request, error) {
|
||||
endpoint := c.baseURL + path
|
||||
if len(params) > 0 {
|
||||
endpoint += "?" + params.Encode()
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Api-Key", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, out any) error {
|
||||
req, err := c.request(ctx, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, out)
|
||||
}
|
||||
|
||||
func (c *Client) post(ctx context.Context, path string, body, out any) error {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("radarr: encode request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Api-Key", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return c.do(req, out)
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request, out any) error {
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("radarr: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("radarr: decode response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package radarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
|
||||
var gotQuery string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/v3/calendar" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("X-Api-Key"); got != "secret" {
|
||||
t.Errorf("X-Api-Key = %q", got)
|
||||
}
|
||||
if r.URL.Query().Get("unmonitored") != "true" {
|
||||
t.Errorf("missing unmonitored flag: %s", r.URL.RawQuery)
|
||||
}
|
||||
if got := r.URL.Query().Get("start"); got != "2026-07-01T00:00:00Z" {
|
||||
t.Errorf("start = %q", got)
|
||||
}
|
||||
gotQuery = r.URL.RawQuery
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[{"id":7,"title":"Arrival","digitalRelease":"2026-08-01T00:00:00Z"}]`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
client := New(upstream.URL, "secret", time.Second)
|
||||
movies, err := client.Calendar(
|
||||
context.Background(),
|
||||
time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC),
|
||||
time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(movies) != 1 || movies[0].ID != 7 || movies[0].DigitalRelease == nil {
|
||||
t.Fatalf("unexpected movies: %+v", movies)
|
||||
}
|
||||
if gotQuery == "" || strings.Contains(gotQuery, "secret") {
|
||||
t.Fatalf("API key leaked into query: %q", gotQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/rootfolder":
|
||||
_, _ = w.Write([]byte(`[{"path":"/movies"}]`))
|
||||
case "/api/v3/qualityprofile":
|
||||
_, _ = w.Write([]byte(`[{"id":4}]`))
|
||||
case "/api/v3/movie":
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["monitored"] != false || body["rootFolderPath"] != "/movies" ||
|
||||
body["qualityProfileId"] != float64(4) {
|
||||
t.Errorf("unexpected add body: %#v", body)
|
||||
}
|
||||
options := body["addOptions"].(map[string]any)
|
||||
if options["searchForMovie"] != false {
|
||||
t.Errorf("movie search was enabled: %#v", body)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":9,"tmdbId":22,"title":"Arrival"}`))
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
added, err := New(upstream.URL, "secret", time.Second).AddUnmonitored(
|
||||
context.Background(), Movie{TMDBID: 22, Title: "Arrival", Monitored: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if added.ID != 9 {
|
||||
t.Fatalf("unexpected movie: %+v", added)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ContextAffinityProfile captures what a viewer tends to watch in broad local-time
|
||||
// windows. It is intentionally compact: seven weekdays by four day parts is enough to
|
||||
// learn household routines without pretending that a small history is a precise model.
|
||||
type ContextAffinityProfile struct {
|
||||
Slots map[string]ContextAffinityBucket `json:"slots,omitempty"`
|
||||
}
|
||||
|
||||
type ContextAffinityBucket struct {
|
||||
Samples int `json:"samples"`
|
||||
GenreWeights map[string]float64 `json:"genres,omitempty"`
|
||||
StudioWeights map[string]float64 `json:"studios,omitempty"`
|
||||
}
|
||||
|
||||
func NewContextAffinityProfile() ContextAffinityProfile {
|
||||
return ContextAffinityProfile{Slots: map[string]ContextAffinityBucket{}}
|
||||
}
|
||||
|
||||
// Add records one matched Tracearr session. Completion and recency determine how much
|
||||
// taste evidence it contributes, while Samples controls confidence separately.
|
||||
func (p *ContextAffinityProfile) Add(
|
||||
item Item,
|
||||
started time.Time,
|
||||
completion float64,
|
||||
recencyPosition int,
|
||||
location *time.Location,
|
||||
) {
|
||||
if started.IsZero() {
|
||||
return
|
||||
}
|
||||
if p.Slots == nil {
|
||||
p.Slots = map[string]ContextAffinityBucket{}
|
||||
}
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
local := started.In(location)
|
||||
key := contextSlotKey(local.Weekday(), dayPart(local.Hour()))
|
||||
bucket := p.Slots[key]
|
||||
if bucket.GenreWeights == nil {
|
||||
bucket.GenreWeights = map[string]float64{}
|
||||
}
|
||||
if bucket.StudioWeights == nil {
|
||||
bucket.StudioWeights = map[string]float64{}
|
||||
}
|
||||
bucket.Samples++
|
||||
weight := (0.2 + clamp01(completion)) * math.Pow(0.985, float64(recencyPosition))
|
||||
for _, genre := range item.Genres {
|
||||
if genre = strings.TrimSpace(genre); genre != "" {
|
||||
bucket.GenreWeights[genre] += weight
|
||||
}
|
||||
}
|
||||
for _, studio := range item.Studios {
|
||||
if name := strings.TrimSpace(studio.Name); name != "" {
|
||||
bucket.StudioWeights[name] += weight * 0.4
|
||||
}
|
||||
}
|
||||
p.Slots[key] = bucket
|
||||
}
|
||||
|
||||
// Score returns a bounded contextual affinity source score and its confidence. Exact
|
||||
// weekday/time behavior matters most; neighboring time windows and the same time on
|
||||
// other days provide progressively weaker fallbacks. No match simply returns zero.
|
||||
func (p ContextAffinityProfile) Score(
|
||||
item Item,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
) (float64, float64) {
|
||||
if len(p.Slots) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
local := now.In(location)
|
||||
part := dayPart(local.Hour())
|
||||
var score, sampleWeight float64
|
||||
for key, bucket := range p.Slots {
|
||||
weekday, bucketPart, ok := parseContextSlotKey(key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
weight := contextSlotSimilarity(local.Weekday(), part, weekday, bucketPart)
|
||||
if weight == 0 {
|
||||
continue
|
||||
}
|
||||
var affinity float64
|
||||
for _, genre := range item.Genres {
|
||||
affinity += weightFold(bucket.GenreWeights, genre)
|
||||
}
|
||||
if n := len(item.Genres); n > 1 {
|
||||
affinity /= math.Sqrt(float64(n))
|
||||
}
|
||||
for _, studio := range item.Studios {
|
||||
affinity += weightFold(bucket.StudioWeights, studio.Name)
|
||||
}
|
||||
score += affinity * weight
|
||||
sampleWeight += float64(bucket.Samples) * weight
|
||||
}
|
||||
if sampleWeight == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
// Five effective sessions are enough for the full (still bounded) contextual nudge.
|
||||
return score / sampleWeight, math.Min(1, sampleWeight/5)
|
||||
}
|
||||
|
||||
// Contextualized returns a copy of the base profile with a modest current-time taste
|
||||
// nudge. It is used by dynamic shelves; the prepared For You pool applies the same
|
||||
// signal directly to candidate placement.
|
||||
func (p ContextAffinityProfile) Contextualized(
|
||||
base Profile,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
) Profile {
|
||||
out := base
|
||||
out.GenreWeights = cloneWeights(base.GenreWeights)
|
||||
out.StudioWeights = cloneWeights(base.StudioWeights)
|
||||
probe := Item{Genres: keysFromWeights(out.GenreWeights)}
|
||||
_, confidence := p.Score(probe, now, location)
|
||||
if confidence == 0 {
|
||||
return out
|
||||
}
|
||||
local := now
|
||||
if location != nil {
|
||||
local = now.In(location)
|
||||
}
|
||||
part := dayPart(local.Hour())
|
||||
for key, bucket := range p.Slots {
|
||||
weekday, bucketPart, ok := parseContextSlotKey(key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
similarity := contextSlotSimilarity(local.Weekday(), part, weekday, bucketPart)
|
||||
if similarity == 0 {
|
||||
continue
|
||||
}
|
||||
scale := 0.35 * confidence * similarity / math.Max(1, float64(bucket.Samples))
|
||||
for genre, weight := range bucket.GenreWeights {
|
||||
out.GenreWeights[genre] += weight * scale
|
||||
}
|
||||
for studio, weight := range bucket.StudioWeights {
|
||||
out.StudioWeights[studio] += weight * scale
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contextSlotSimilarity(currentDay time.Weekday, currentPart int, day time.Weekday, part int) float64 {
|
||||
dayDistance := int(currentDay) - int(day)
|
||||
if dayDistance < 0 {
|
||||
dayDistance = -dayDistance
|
||||
}
|
||||
if dayDistance > 3 {
|
||||
dayDistance = 7 - dayDistance
|
||||
}
|
||||
partDistance := currentPart - part
|
||||
if partDistance < 0 {
|
||||
partDistance = -partDistance
|
||||
}
|
||||
switch {
|
||||
case dayDistance == 0 && partDistance == 0:
|
||||
return 1
|
||||
case dayDistance == 0 && partDistance == 1:
|
||||
return 0.35
|
||||
case dayDistance == 1 && partDistance == 0:
|
||||
return 0.25
|
||||
case partDistance == 0:
|
||||
return 0.12
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func dayPart(hour int) int {
|
||||
switch {
|
||||
case hour >= 5 && hour < 11:
|
||||
return 0
|
||||
case hour >= 11 && hour < 17:
|
||||
return 1
|
||||
case hour >= 17 && hour < 22:
|
||||
return 2
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
func contextSlotKey(day time.Weekday, part int) string {
|
||||
return string(rune('0'+day)) + ":" + string(rune('0'+part))
|
||||
}
|
||||
|
||||
func parseContextSlotKey(key string) (time.Weekday, int, bool) {
|
||||
if len(key) != 3 || key[1] != ':' || key[0] < '0' || key[0] > '6' ||
|
||||
key[2] < '0' || key[2] > '3' {
|
||||
return 0, 0, false
|
||||
}
|
||||
return time.Weekday(key[0] - '0'), int(key[2] - '0'), true
|
||||
}
|
||||
|
||||
func clamp01(value float64) float64 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 1 {
|
||||
return 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func cloneWeights(source map[string]float64) map[string]float64 {
|
||||
out := make(map[string]float64, len(source))
|
||||
for key, value := range source {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func keysFromWeights(source map[string]float64) []string {
|
||||
out := make([]string, 0, len(source))
|
||||
for key := range source {
|
||||
out = append(out, key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContextAffinityPrefersTypicalWeekdayTime(t *testing.T) {
|
||||
location := time.FixedZone("test", 12*60*60)
|
||||
profile := NewContextAffinityProfile()
|
||||
comedy := Item{Genres: []string{"Comedy"}}
|
||||
drama := Item{Genres: []string{"Drama"}}
|
||||
for i := 0; i < 6; i++ {
|
||||
profile.Add(
|
||||
comedy,
|
||||
time.Date(2026, 7, 6+i*7, 19, 30, 0, 0, location),
|
||||
1,
|
||||
i,
|
||||
location,
|
||||
)
|
||||
profile.Add(
|
||||
drama,
|
||||
time.Date(2026, 7, 7+i*7, 13, 0, 0, 0, location),
|
||||
1,
|
||||
i,
|
||||
location,
|
||||
)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 27, 20, 0, 0, 0, location) // Monday evening.
|
||||
comedyScore, confidence := profile.Score(comedy, now, location)
|
||||
dramaScore, _ := profile.Score(drama, now, location)
|
||||
if comedyScore <= dramaScore {
|
||||
t.Fatalf("Monday-evening comedy score %.3f <= drama score %.3f", comedyScore, dramaScore)
|
||||
}
|
||||
if confidence != 1 {
|
||||
t.Fatalf("confidence = %.3f, want 1 after repeated matching sessions", confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextAffinityUsesNeighboringWindowsAsWeakFallback(t *testing.T) {
|
||||
location := time.UTC
|
||||
profile := NewContextAffinityProfile()
|
||||
item := Item{Genres: []string{"Documentary"}}
|
||||
profile.Add(
|
||||
item,
|
||||
time.Date(2026, 7, 20, 16, 30, 0, 0, location), // Monday afternoon.
|
||||
0.8,
|
||||
0,
|
||||
location,
|
||||
)
|
||||
|
||||
exact, exactConfidence := profile.Score(
|
||||
item, time.Date(2026, 7, 27, 16, 0, 0, 0, location), location,
|
||||
)
|
||||
neighbor, neighborConfidence := profile.Score(
|
||||
item, time.Date(2026, 7, 27, 18, 0, 0, 0, location), location,
|
||||
)
|
||||
if neighbor <= 0 || neighbor*neighborConfidence >= exact*exactConfidence {
|
||||
t.Fatalf(
|
||||
"weighted neighbor %.3f should be positive and below exact %.3f",
|
||||
neighbor*neighborConfidence,
|
||||
exact*exactConfidence,
|
||||
)
|
||||
}
|
||||
if neighborConfidence >= exactConfidence {
|
||||
t.Fatalf(
|
||||
"neighbor confidence %.3f should be below exact %.3f",
|
||||
neighborConfidence,
|
||||
exactConfidence,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextAffinityHasNeutralSparseHistoryFallback(t *testing.T) {
|
||||
score, confidence := (ContextAffinityProfile{}).Score(
|
||||
Item{Genres: []string{"Drama"}}, time.Now(), time.UTC,
|
||||
)
|
||||
if score != 0 || confidence != 0 {
|
||||
t.Fatalf("empty profile = score %.3f confidence %.3f, want neutral", score, confidence)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package recommend
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/url"
|
||||
@@ -54,6 +55,12 @@ type CuratedLibrarySource interface {
|
||||
) ([]json.RawMessage, error)
|
||||
}
|
||||
|
||||
// GenreLibrarySource lets an imported Sonarr/Radarr/Emby catalogue describe itself.
|
||||
// It is optional so recommendation sources that predate genre discovery still work.
|
||||
type GenreLibrarySource interface {
|
||||
LibraryGenres(ctx context.Context, itemTypes []string, minItems int) ([]string, error)
|
||||
}
|
||||
|
||||
type TracearrSource interface {
|
||||
History(ctx context.Context, username string, limit int) ([]tracearr.Session, error)
|
||||
}
|
||||
@@ -96,6 +103,12 @@ type Engine struct {
|
||||
MaxSimilarRows int
|
||||
RowSize int
|
||||
CuratedRows []CuratedRow
|
||||
WeightedConfig WeightedConfig
|
||||
|
||||
// Location defines the viewer's local day/time windows. Now is injectable so
|
||||
// contextual placement stays deterministic in tests.
|
||||
Location *time.Location
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// ForYouOptions are request-scoped constraints chosen on the television.
|
||||
@@ -125,12 +138,13 @@ func (e *Engine) BuildForYou(
|
||||
profile := BuildProfile(history, favorites)
|
||||
|
||||
var sessions []tracearr.Session
|
||||
contextAffinity := NewContextAffinityProfile()
|
||||
if e.Tracearr != nil {
|
||||
if fetched, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
|
||||
e.log.Warn("tracearr history unavailable; using emby signals", "error", traceErr)
|
||||
} else {
|
||||
sessions = recommendationSessions(fetched)
|
||||
e.applyTracearrSignals(&profile, history, sessions)
|
||||
contextAffinity = e.applyTracearrSignals(&profile, history, sessions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +177,10 @@ func (e *Engine) BuildForYou(
|
||||
}
|
||||
|
||||
compatibility := buildCompatibilityProfile(sessions)
|
||||
items := rankForYou(profile, candidates, options.AvailableMinutes, compatibility, e.RowSize)
|
||||
items := rankForYouAt(
|
||||
profile, candidates, options.AvailableMinutes, compatibility, e.RowSize,
|
||||
contextAffinity, e.now(), e.Location,
|
||||
)
|
||||
if len(items) < e.MinRowItems {
|
||||
return []Row{}, nil
|
||||
}
|
||||
@@ -199,7 +216,8 @@ func (e *Engine) applyTracearrSignals(
|
||||
profile *Profile,
|
||||
history []Item,
|
||||
sessions []tracearr.Session,
|
||||
) {
|
||||
) ContextAffinityProfile {
|
||||
contextAffinity := NewContextAffinityProfile()
|
||||
byTitle := make(map[string]Item, len(history))
|
||||
for _, item := range history {
|
||||
byTitle[item.TitleKey()] = item
|
||||
@@ -216,7 +234,11 @@ func (e *Engine) applyTracearrSignals(
|
||||
// minutes"; recency lets changing tastes move promptly.
|
||||
weight := (0.2 + session.Completion()) * powDecay(0.985, i)
|
||||
profile.absorbTaste(item, weight)
|
||||
if started, ok := parseTracearrTime(session.StartedAt); ok {
|
||||
contextAffinity.Add(item, started, session.Completion(), i, e.Location)
|
||||
}
|
||||
}
|
||||
return contextAffinity
|
||||
}
|
||||
|
||||
func (e *Engine) libraryCandidatesForYou(
|
||||
@@ -307,13 +329,32 @@ func rankForYou(
|
||||
availableMinutes int,
|
||||
compatibility compatibilityProfile,
|
||||
limit int,
|
||||
) []Item {
|
||||
return rankForYouAt(
|
||||
profile, candidates, availableMinutes, compatibility, limit,
|
||||
ContextAffinityProfile{}, time.Time{}, nil,
|
||||
)
|
||||
}
|
||||
|
||||
func rankForYouAt(
|
||||
profile Profile,
|
||||
candidates []Item,
|
||||
availableMinutes int,
|
||||
compatibility compatibilityProfile,
|
||||
limit int,
|
||||
contextAffinity ContextAffinityProfile,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
) []Item {
|
||||
type scored struct {
|
||||
item Item
|
||||
score float64
|
||||
item Item
|
||||
score float64
|
||||
contextRaw float64
|
||||
confidence float64
|
||||
}
|
||||
ranked := make([]scored, 0, len(candidates))
|
||||
seen := map[string]bool{}
|
||||
var maxContext float64
|
||||
for _, candidate := range candidates {
|
||||
if seen[candidate.ID] {
|
||||
continue
|
||||
@@ -333,7 +374,20 @@ func rankForYou(
|
||||
// allowing runtime to overwhelm taste.
|
||||
score += float64(runtime) / float64(availableMinutes) * 0.35
|
||||
}
|
||||
ranked = append(ranked, scored{item: candidate, score: score})
|
||||
contextRaw, confidence := contextAffinity.Score(candidate, now, location)
|
||||
if contextRaw > maxContext {
|
||||
maxContext = contextRaw
|
||||
}
|
||||
ranked = append(ranked, scored{
|
||||
item: candidate, score: score, contextRaw: contextRaw, confidence: confidence,
|
||||
})
|
||||
}
|
||||
if maxContext > 0 {
|
||||
for i := range ranked {
|
||||
// A maximum 1.5-point lift is enough to rearrange similarly relevant
|
||||
// posters without allowing a routine to overpower taste or quality.
|
||||
ranked[i].score += 1.5 * ranked[i].confidence * ranked[i].contextRaw / maxContext
|
||||
}
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].score != ranked[j].score {
|
||||
@@ -351,6 +405,13 @@ func rankForYou(
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *Engine) now() time.Time {
|
||||
if e.Now != nil {
|
||||
return e.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func explainRecommendation(
|
||||
profile Profile,
|
||||
item Item,
|
||||
@@ -432,6 +493,7 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
|
||||
MinRowItems: 4,
|
||||
MaxSimilarRows: 2,
|
||||
RowSize: 20,
|
||||
WeightedConfig: DefaultWeightedConfig(),
|
||||
CuratedRows: []CuratedRow{
|
||||
{
|
||||
ID: "curated:apple-tv",
|
||||
@@ -518,8 +580,8 @@ func movieStudioRow(id, title string, studios ...string) CuratedRow {
|
||||
}
|
||||
|
||||
const (
|
||||
historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks"
|
||||
candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,PrimaryImageAspectRatio"
|
||||
historyFields = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,SeriesName,ProductionYear,PremiereDate,RunTimeTicks"
|
||||
candidateFields = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
|
||||
rowImageTypes = "Backdrop,Primary,Logo"
|
||||
)
|
||||
|
||||
@@ -528,12 +590,45 @@ const (
|
||||
// Cost is a handful of Emby queries, which is why callers cache the result rather than
|
||||
// computing it on every home load.
|
||||
func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, error) {
|
||||
return e.BuildRowsForUser(ctx, cred, "")
|
||||
}
|
||||
|
||||
// BuildRowsForUser combines Emby state with Tracearr playback outcomes, Memby's own
|
||||
// browsing events and the imported Sonarr/Radarr-backed catalogue. BuildRows remains as
|
||||
// the compatibility entry point for tests and callers that do not know a username.
|
||||
func (e *Engine) BuildRowsForUser(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
username string,
|
||||
) ([]Row, error) {
|
||||
history, favorites, err := e.gatherSignals(ctx, cred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profile := BuildProfile(history, favorites)
|
||||
contextAffinity := NewContextAffinityProfile()
|
||||
if e.Tracearr != nil && strings.TrimSpace(username) != "" {
|
||||
if sessions, traceErr := e.Tracearr.History(ctx, username, 300); traceErr != nil {
|
||||
e.log.Warn("tracearr signals unavailable for shelves", "error", traceErr)
|
||||
} else {
|
||||
contextAffinity = e.applyTracearrSignals(
|
||||
&profile, history, recommendationSessions(sessions),
|
||||
)
|
||||
}
|
||||
}
|
||||
if e.Behavior != nil {
|
||||
if raws, browseErr := e.Behavior.BrowsingCandidates(
|
||||
ctx, cred.UserID, time.Now().Add(-30*24*time.Hour), 40,
|
||||
); browseErr != nil {
|
||||
e.log.Warn("browsing signals unavailable for shelves", "error", browseErr)
|
||||
} else {
|
||||
for i, item := range Decode(raws) {
|
||||
profile.absorbTaste(item, 0.55*powDecay(0.92, i))
|
||||
}
|
||||
}
|
||||
}
|
||||
profile = contextAffinity.Contextualized(profile, e.now(), e.Location)
|
||||
|
||||
rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
|
||||
if !profile.IsEmpty() {
|
||||
@@ -548,22 +643,26 @@ func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, e
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
rows = append(rows, e.buildCuratedRows(ctx, profile)...)
|
||||
rows = append(rows, e.buildCuratedRows(ctx, profile, dailySeed(cred.UserID))...)
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile, seed string) []Row {
|
||||
library, ok := e.Library.(CuratedLibrarySource)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
curatedDefinitions := append([]CuratedRow(nil), e.CuratedRows...)
|
||||
if genres, supportsGenres := e.Library.(GenreLibrarySource); supportsGenres {
|
||||
curatedDefinitions = e.catalogueGenreRows(ctx, genres, curatedDefinitions)
|
||||
}
|
||||
type rankedDefinition struct {
|
||||
definition CuratedRow
|
||||
affinity float64
|
||||
order int
|
||||
}
|
||||
definitions := make([]rankedDefinition, 0, len(e.CuratedRows))
|
||||
for order, definition := range e.CuratedRows {
|
||||
definitions := make([]rankedDefinition, 0, len(curatedDefinitions))
|
||||
for order, definition := range curatedDefinitions {
|
||||
affinity := profile.CollectionAffinity(definition.Genres, definition.Studios)
|
||||
// Studio signals are intentionally damped while scoring individual titles.
|
||||
// Restore enough weight at shelf level for a genuinely followed studio to earn
|
||||
@@ -585,6 +684,11 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
if definitions[i].affinity != definitions[j].affinity {
|
||||
return definitions[i].affinity > definitions[j].affinity
|
||||
}
|
||||
left := stableVariation(seed, definitions[i].definition.ID)
|
||||
right := stableVariation(seed, definitions[j].definition.ID)
|
||||
if left != right {
|
||||
return left < right
|
||||
}
|
||||
return definitions[i].order < definitions[j].order
|
||||
})
|
||||
|
||||
@@ -592,20 +696,8 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
// A series may carry several genres. Give it to the user's highest-affinity shelf
|
||||
// only, so scrolling Shows never reveals the same card again under another label.
|
||||
seenItems := make(map[string]struct{})
|
||||
movieGenreRows := 0
|
||||
movieStudioRows := 0
|
||||
for _, ranked := range definitions {
|
||||
definition := ranked.definition
|
||||
switch {
|
||||
case strings.HasPrefix(definition.ID, "curated:movies:genre:"):
|
||||
if movieGenreRows >= 6 {
|
||||
continue
|
||||
}
|
||||
case strings.HasPrefix(definition.ID, "curated:movies:studio:"):
|
||||
if movieStudioRows >= 3 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
raws, err := library.CuratedCandidates(
|
||||
ctx,
|
||||
definition.ItemTypes,
|
||||
@@ -618,6 +710,7 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
continue
|
||||
}
|
||||
rankedItems := RankCollection(profile, Decode(raws), e.RowSize*2)
|
||||
rankedItems = diversifyRanked(rankedItems, seed+":"+definition.ID, 5)
|
||||
items := make([]Item, 0, e.RowSize)
|
||||
for _, item := range rankedItems {
|
||||
if _, seen := seenItems[item.ID]; seen {
|
||||
@@ -640,16 +733,106 @@ func (e *Engine) buildCuratedRows(ctx context.Context, profile Profile) []Row {
|
||||
Kind: definition.Kind,
|
||||
Items: Raws(items),
|
||||
})
|
||||
if strings.HasPrefix(definition.ID, "curated:movies:genre:") {
|
||||
movieGenreRows++
|
||||
}
|
||||
if strings.HasPrefix(definition.ID, "curated:movies:studio:") {
|
||||
movieStudioRows++
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (e *Engine) catalogueGenreRows(
|
||||
ctx context.Context,
|
||||
source GenreLibrarySource,
|
||||
definitions []CuratedRow,
|
||||
) []CuratedRow {
|
||||
// Ask for extra depth because watched titles and cross-shelf deduplication reduce
|
||||
// the usable pool. A genre is emitted only if MinRowItems unseen cards survive.
|
||||
minimumDepth := e.MinRowItems * 2
|
||||
for _, itemType := range []string{"Series", "Movie"} {
|
||||
genres, err := source.LibraryGenres(ctx, []string{itemType}, minimumDepth)
|
||||
if err != nil {
|
||||
e.log.Warn("library genre discovery failed", "type", itemType, "error", err)
|
||||
continue
|
||||
}
|
||||
for _, genre := range genres {
|
||||
idPrefix, titleSuffix, kind := "curated:shows:genre:", " Shows", "shows"
|
||||
if itemType == "Movie" {
|
||||
idPrefix, titleSuffix, kind = "curated:movies:genre:", " Movies", "movies"
|
||||
}
|
||||
id := idPrefix + rowSlug(genre)
|
||||
replaced := false
|
||||
for index := range definitions {
|
||||
existing := &definitions[index]
|
||||
if existing.Kind == kind && len(existing.Genres) == 1 &&
|
||||
strings.EqualFold(existing.Genres[0], genre) {
|
||||
// Catalogue depth proves this can be a useful shelf even when it
|
||||
// is outside the viewer's established affinity.
|
||||
existing.RequireAffinity = false
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
definitions = append(definitions, CuratedRow{
|
||||
ID: id, Title: genre + titleSuffix, Kind: kind,
|
||||
ItemTypes: []string{itemType}, Genres: []string{genre},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return definitions
|
||||
}
|
||||
|
||||
func rowSlug(value string) string {
|
||||
var out strings.Builder
|
||||
dash := false
|
||||
for _, r := range strings.ToLower(strings.TrimSpace(value)) {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||
if dash && out.Len() > 0 {
|
||||
out.WriteByte('-')
|
||||
}
|
||||
out.WriteRune(r)
|
||||
dash = false
|
||||
} else {
|
||||
dash = true
|
||||
}
|
||||
}
|
||||
if out.Len() > 0 {
|
||||
return out.String()
|
||||
}
|
||||
return strconv.FormatUint(stableVariation("", value), 36)
|
||||
}
|
||||
|
||||
func dailySeed(userID string) string {
|
||||
return userID + ":" + time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
func stableVariation(seed, value string) uint64 {
|
||||
hash := fnv.New64a()
|
||||
_, _ = hash.Write([]byte(seed))
|
||||
_, _ = hash.Write([]byte{0})
|
||||
_, _ = hash.Write([]byte(value))
|
||||
return hash.Sum64()
|
||||
}
|
||||
|
||||
// diversifyRanked preserves relevance bands while changing the exact poster sequence
|
||||
// per user and day. Affinity/rating still choose each five-card band; variation only
|
||||
// decides which equally strong-looking option catches the eye first.
|
||||
func diversifyRanked(items []Item, seed string, bandSize int) []Item {
|
||||
if bandSize < 2 || len(items) < 2 {
|
||||
return items
|
||||
}
|
||||
out := append([]Item(nil), items...)
|
||||
for start := 0; start < len(out); start += bandSize {
|
||||
end := start + bandSize
|
||||
if end > len(out) {
|
||||
end = len(out)
|
||||
}
|
||||
sort.SliceStable(out[start:end], func(i, j int) bool {
|
||||
return stableVariation(seed, out[start+i].ID) <
|
||||
stableVariation(seed, out[start+j].ID)
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gatherSignals reads what the user has watched and favourited, in parallel.
|
||||
func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) {
|
||||
var (
|
||||
@@ -795,6 +978,59 @@ func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile
|
||||
}, true
|
||||
}
|
||||
|
||||
// RelatedTo answers a detail page: why this viewer might enjoy one title, and what else
|
||||
// in the library is like it.
|
||||
//
|
||||
// Both halves come from the same profile the home rows are built from, so the page can
|
||||
// never explain a taste the engine does not hold. A failed Similar lookup still returns
|
||||
// the reasons — the strip under the description is worth more than the carousel.
|
||||
func (e *Engine) RelatedTo(
|
||||
ctx context.Context,
|
||||
cred emby.Credentials,
|
||||
item Item,
|
||||
limit int,
|
||||
) (reasons []string, related []Item, err error) {
|
||||
history, favorites, err := e.gatherSignals(ctx, cred)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
profile := BuildProfile(history, favorites)
|
||||
reasons = Why(profile, item, ReasonLimit)
|
||||
|
||||
if limit <= 0 {
|
||||
limit = e.RowSize
|
||||
}
|
||||
result, similarErr := e.source.Similar(ctx, cred, item.ID, url.Values{
|
||||
"UserId": {cred.UserID},
|
||||
"Limit": {strconv.Itoa(limit * 2)},
|
||||
"Fields": {candidateFields},
|
||||
"ImageTypeLimit": {"1"},
|
||||
"EnableImages": {"true"},
|
||||
"EnableImageTypes": {rowImageTypes},
|
||||
"EnableUserData": {"true"},
|
||||
})
|
||||
if similarErr != nil {
|
||||
e.log.Warn("related lookup failed", "item", item.ID, "error", similarErr)
|
||||
return reasons, nil, nil
|
||||
}
|
||||
|
||||
candidates := Decode(result.Items)
|
||||
related = FilterUnseen(profile, candidates, limit)
|
||||
// A carousel of two looks broken. Someone deep into a franchise has seen most of
|
||||
// what resembles it, so fall back to Emby's unfiltered order rather than a stub.
|
||||
if len(related) < e.MinRowItems && len(candidates) > len(related) {
|
||||
related = trim(candidates, limit)
|
||||
}
|
||||
return reasons, related, nil
|
||||
}
|
||||
|
||||
func trim(items []Item, limit int) []Item {
|
||||
if limit > 0 && len(items) > limit {
|
||||
return items[:limit]
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// historyRow is the genre-affinity row: unwatched titles from the genres the user has
|
||||
// been spending time in, ranked by how closely they match the whole profile.
|
||||
func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile Profile) (Row, bool) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
@@ -36,6 +37,22 @@ type fakeCuratedLibrary struct {
|
||||
byGenre map[string][]json.RawMessage
|
||||
}
|
||||
|
||||
type fakeGenreLibrary struct {
|
||||
*fakeCuratedLibrary
|
||||
genresByType map[string][]string
|
||||
}
|
||||
|
||||
func (f *fakeGenreLibrary) LibraryGenres(
|
||||
_ context.Context,
|
||||
itemTypes []string,
|
||||
_ int,
|
||||
) ([]string, error) {
|
||||
if len(itemTypes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return f.genresByType[itemTypes[0]], nil
|
||||
}
|
||||
|
||||
type fakeForYouLibrary struct {
|
||||
items []json.RawMessage
|
||||
}
|
||||
@@ -125,10 +142,12 @@ func (f *fakeSource) NextUp(
|
||||
return &emby.ItemsResult{Items: f.nextUp}, nil
|
||||
}
|
||||
|
||||
func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T) {
|
||||
func TestAbandonedShowsRequireAnEmbyNextUpAndStayInTheFirstSeason(t *testing.T) {
|
||||
now := time.Date(2026, time.July, 29, 12, 0, 0, 0, time.UTC)
|
||||
catalogue := Decode([]json.RawMessage{
|
||||
json.RawMessage(`{"Id":"early","Name":"Early Show","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"finished-first","Name":"Finished First","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"unknown-season","Name":"Unknown Season","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"deep","Name":"Deep Show","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"complete","Name":"Complete Show","Type":"Series"}`),
|
||||
json.RawMessage(`{"Id":"recent","Name":"Recent Show","Type":"Series"}`),
|
||||
@@ -143,12 +162,18 @@ func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T)
|
||||
}
|
||||
sessions := []tracearr.Session{
|
||||
session("Early Show", 1, 2, 40),
|
||||
session("Finished First", 1, 10, 50),
|
||||
session("Deep Show", 2, 8, 60),
|
||||
session("Complete Show", 4, 10, 50),
|
||||
session("Recent Show", 1, 3, 5),
|
||||
}
|
||||
unknown := session("Unknown Season", 1, 2, 30)
|
||||
unknown.SeasonNumber = nil
|
||||
sessions = append(sessions, unknown)
|
||||
nextUp := Decode([]json.RawMessage{
|
||||
json.RawMessage(`{"Id":"early-next","Type":"Episode","SeriesId":"early","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
|
||||
json.RawMessage(`{"Id":"finished-next","Type":"Episode","SeriesId":"finished-first","ParentIndexNumber":2,"IndexNumber":1,"RunTimeTicks":18000000000}`),
|
||||
json.RawMessage(`{"Id":"unknown-next","Type":"Episode","SeriesId":"unknown-season","ParentIndexNumber":1,"IndexNumber":3,"RunTimeTicks":18000000000}`),
|
||||
json.RawMessage(`{"Id":"deep-next","Type":"Episode","SeriesId":"deep","ParentIndexNumber":3,"IndexNumber":1,"RunTimeTicks":36000000000}`),
|
||||
json.RawMessage(`{"Id":"recent-next","Type":"Episode","SeriesId":"recent","ParentIndexNumber":1,"IndexNumber":4}`),
|
||||
})
|
||||
@@ -160,20 +185,21 @@ func TestAbandonedShowsRequireAnEmbyNextUpAndRespectSeasonProgress(t *testing.T)
|
||||
compatibilityProfile{directCodecs: map[string]int{}, transcodeCodecs: map[string]int{}},
|
||||
now,
|
||||
)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("pickup candidates = %+v, want only two abandoned unfinished shows", got)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("pickup candidates = %+v, want three early-show abandonments", got)
|
||||
}
|
||||
if got[0].ItemID != "deep" || got[1].ItemID != "early" {
|
||||
t.Fatalf("pickup order = %q, %q; later-season commitment should lead", got[0].ItemID, got[1].ItemID)
|
||||
byID := map[string]PreparedCandidate{}
|
||||
for _, candidate := range got {
|
||||
byID[candidate.ItemID] = candidate
|
||||
}
|
||||
if got[0].RecommendationReason != "You made it through season 2 · season 3 is waiting" {
|
||||
t.Fatalf("later-season reason = %q", got[0].RecommendationReason)
|
||||
if byID["early"].RecommendationReason != "You left this in season 1 · pick it up again" {
|
||||
t.Fatalf("season-one reason = %q", byID["early"].RecommendationReason)
|
||||
}
|
||||
if got[1].RecommendationReason != "You left this in season 1 · pick it up again" {
|
||||
t.Fatalf("season-one reason = %q", got[1].RecommendationReason)
|
||||
if byID["finished-first"].RecommendationReason != "You finished season 1 · season 2 is waiting" {
|
||||
t.Fatalf("season-two waiting reason = %q", byID["finished-first"].RecommendationReason)
|
||||
}
|
||||
if got[0].RuntimeMinutes != 60 || got[1].RuntimeMinutes != 30 {
|
||||
t.Fatalf("next-episode runtimes = %d, %d", got[0].RuntimeMinutes, got[1].RuntimeMinutes)
|
||||
if byID["unknown-season"].RecommendationReason != "You left this in season 1 · pick it up again" {
|
||||
t.Fatalf("inferred first-season reason = %q", byID["unknown-season"].RecommendationReason)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +535,28 @@ func TestRecommendationSessionsDiscardPrerolls(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpisodeEvidenceUsesParentSeriesMetadata(t *testing.T) {
|
||||
seriesRaw, _ := json.Marshal(map[string]any{
|
||||
"Id": "series-1", "Name": "The Show", "Type": "Series",
|
||||
"People": []map[string]string{{"Name": "Lead Actor", "Type": "Actor"}},
|
||||
"Studios": []map[string]string{{"Name": "Great Studio"}},
|
||||
"Genres": []string{"Drama"},
|
||||
})
|
||||
episodeRaw, _ := json.Marshal(map[string]any{
|
||||
"Id": "episode-1", "Name": "Pilot", "Type": "Episode",
|
||||
"SeriesId": "series-1", "SeriesName": "The Show",
|
||||
})
|
||||
series := Decode([]json.RawMessage{seriesRaw})[0]
|
||||
episode := Decode([]json.RawMessage{episodeRaw})[0]
|
||||
|
||||
got := newCatalogueIndex([]Item{series}).evidenceItem(episode)
|
||||
|
||||
if got.ID != "series-1" || len(got.People) != 1 ||
|
||||
len(got.Studios) != 1 || len(got.Genres) != 1 {
|
||||
t.Fatalf("episode evidence was not enriched from its series: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
itemsByFilter: map[string][]json.RawMessage{
|
||||
@@ -731,6 +779,65 @@ func TestCuratedRowsFallBackToRatingForANewUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogueGenresExpandMovieAndShowShelves(t *testing.T) {
|
||||
source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}}
|
||||
engine := testEngine(source)
|
||||
engine.Library = &fakeGenreLibrary{
|
||||
fakeCuratedLibrary: &fakeCuratedLibrary{byGenre: map[string][]json.RawMessage{
|
||||
"Western": {
|
||||
raw("western-1", "Western One", "Movie", "Western"),
|
||||
raw("western-2", "Western Two", "Movie", "Western"),
|
||||
},
|
||||
"Reality": {
|
||||
raw("reality-1", "Reality One", "Series", "Reality"),
|
||||
raw("reality-2", "Reality Two", "Series", "Reality"),
|
||||
},
|
||||
}},
|
||||
genresByType: map[string][]string{
|
||||
"Movie": {"Western"},
|
||||
"Series": {"Reality"},
|
||||
},
|
||||
}
|
||||
|
||||
rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "new"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
got[row.ID] = true
|
||||
}
|
||||
if !got["curated:movies:genre:western"] || !got["curated:shows:genre:reality"] {
|
||||
t.Fatalf("catalogue-backed genre rows missing: %v", rowTitles(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiversifyRankedIsStableAndOnlyReordersWithinBands(t *testing.T) {
|
||||
items := make([]Item, 0, 12)
|
||||
for i := 0; i < 12; i++ {
|
||||
items = append(items, Item{ID: strconv.Itoa(i)})
|
||||
}
|
||||
first := diversifyRanked(items, "user:day", 5)
|
||||
second := diversifyRanked(items, "user:day", 5)
|
||||
if fmt.Sprint(first) != fmt.Sprint(second) {
|
||||
t.Fatal("same user/day seed must produce stable poster ordering")
|
||||
}
|
||||
for index, item := range first {
|
||||
if index/5 != mustAtoi(t, item.ID)/5 {
|
||||
t.Fatalf("item %s escaped its relevance band: %+v", item.ID, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustAtoi(t *testing.T, value string) int {
|
||||
t.Helper()
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// A failing similarity lookup is one dead row, not a dead home screen.
|
||||
func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) {
|
||||
source := &fakeSource{
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Why a title suits one viewer, in that viewer's own words.
|
||||
//
|
||||
// This is deliberately separate from Score. The scorer decides *order* and is allowed to
|
||||
// be opaque; this decides *wording* and must never claim an affinity the profile did not
|
||||
// actually learn — every reason below is read straight out of the weights built from real
|
||||
// history, so a viewer who has watched nothing gets the honest, taste-free ones.
|
||||
|
||||
// ReasonLimit is what fits on one line of a detail page without wrapping. Beyond three
|
||||
// the strip stops reading as an explanation and starts reading as marketing.
|
||||
const ReasonLimit = 3
|
||||
|
||||
// reasonFloor is the weight below which an affinity is a coincidence rather than a
|
||||
// habit — one stray episode should not put a genre on the screen as a reason.
|
||||
const reasonFloor = 0.35
|
||||
|
||||
// Why returns up to limit short phrases explaining the item to this viewer, strongest
|
||||
// first. Never nil: a profile with nothing in it still yields the catalogue facts.
|
||||
func Why(profile Profile, item Item, limit int) []string {
|
||||
if limit <= 0 {
|
||||
limit = ReasonLimit
|
||||
}
|
||||
reasons := make([]string, 0, limit)
|
||||
add := func(reason string) {
|
||||
if len(reasons) < limit && reason != "" {
|
||||
reasons = append(reasons, reason)
|
||||
}
|
||||
}
|
||||
|
||||
if genre, weight := heaviest(profile.GenreWeights, item.Genres); weight >= reasonFloor {
|
||||
add("Because you watch " + genre)
|
||||
}
|
||||
if name, weight := heaviestPerson(profile, item); weight >= reasonFloor {
|
||||
add("You've watched " + name + " before")
|
||||
}
|
||||
if studio, weight := heaviest(profile.StudioWeights, studioNames(item)); weight >= reasonFloor {
|
||||
add("More from " + studio)
|
||||
}
|
||||
|
||||
// Catalogue facts, used to fill the strip out. They are true for everyone, which is
|
||||
// exactly why they come last: they explain the title, not the viewer.
|
||||
// Kept short deliberately. Three chips share one line on a 960dp TV, and this is the
|
||||
// one that most often lands third, where a long phrase is the one that gets clipped.
|
||||
if item.CommunityRating >= 7.5 {
|
||||
add("Well rated (" + strconv.FormatFloat(round1(item.CommunityRating), 'f', 1, 64) + ")")
|
||||
}
|
||||
if item.ProductionYear > 0 && time.Now().Year()-item.ProductionYear <= 1 {
|
||||
add("A recent release")
|
||||
}
|
||||
if len(reasons) == 0 && len(item.Genres) > 0 {
|
||||
add(strings.TrimSpace(item.Genres[0]) + " from your library")
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
// heaviest picks the wanted key with the most weight behind it, matched case-insensitively
|
||||
// because Emby's own tagging is not consistent about it. Ties break alphabetically so the
|
||||
// same profile and item always produce the same sentence.
|
||||
func heaviest(weights map[string]float64, wanted []string) (string, float64) {
|
||||
best, bestWeight := "", 0.0
|
||||
for _, candidate := range wanted {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
weight := weightFold(weights, candidate)
|
||||
if weight <= 0 {
|
||||
continue
|
||||
}
|
||||
if weight > bestWeight || (weight == bestWeight && candidate < best) {
|
||||
best, bestWeight = candidate, weight
|
||||
}
|
||||
}
|
||||
return best, bestWeight
|
||||
}
|
||||
|
||||
func heaviestPerson(profile Profile, item Item) (string, float64) {
|
||||
names := make([]string, 0, len(item.People))
|
||||
for _, person := range item.People {
|
||||
if isExplainablePerson(person.Type) {
|
||||
names = append(names, person.Name)
|
||||
}
|
||||
}
|
||||
return heaviest(profile.PersonWeights, names)
|
||||
}
|
||||
|
||||
func round1(value float64) float64 {
|
||||
return float64(int(value*10+0.5)) / 10
|
||||
}
|
||||
|
||||
// TopPeople is the explanation layer's view of the cast a viewer follows, heaviest first.
|
||||
// Exported for the admin page, which shows what the engine believes about a household.
|
||||
func (p Profile) TopPeople(n int) []string {
|
||||
type kv struct {
|
||||
name string
|
||||
weight float64
|
||||
}
|
||||
pairs := make([]kv, 0, len(p.PersonWeights))
|
||||
for name, weight := range p.PersonWeights {
|
||||
pairs = append(pairs, kv{name, weight})
|
||||
}
|
||||
sort.Slice(pairs, func(i, j int) bool {
|
||||
if pairs[i].weight != pairs[j].weight {
|
||||
return pairs[i].weight > pairs[j].weight
|
||||
}
|
||||
return pairs[i].name < pairs[j].name
|
||||
})
|
||||
if n > len(pairs) {
|
||||
n = len(pairs)
|
||||
}
|
||||
out := make([]string, 0, n)
|
||||
for _, pair := range pairs[:n] {
|
||||
out = append(out, pair.name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func explainItem(t *testing.T, payload string) Item {
|
||||
t.Helper()
|
||||
items := Decode([]json.RawMessage{json.RawMessage(payload)})
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("payload did not decode to one item: %s", payload)
|
||||
}
|
||||
return items[0]
|
||||
}
|
||||
|
||||
func explainProfile(t *testing.T, history ...string) Profile {
|
||||
t.Helper()
|
||||
items := make([]Item, 0, len(history))
|
||||
for _, payload := range history {
|
||||
items = append(items, explainItem(t, payload))
|
||||
}
|
||||
return BuildProfile(items, nil)
|
||||
}
|
||||
|
||||
const thrillerHistory = `{
|
||||
"Id":"h1","Name":"Sicario","Type":"Movie","Genres":["Thriller","Crime"],
|
||||
"Studios":[{"Name":"Lionsgate"}],
|
||||
"People":[{"Name":"Denis Villeneuve","Type":"Director"},{"Name":"Emily Blunt","Type":"Actor"}]
|
||||
}`
|
||||
|
||||
func TestWhyNamesTheGenreTheViewerActuallyWatches(t *testing.T) {
|
||||
profile := explainProfile(t, thrillerHistory)
|
||||
candidate := explainItem(t, `{
|
||||
"Id":"c1","Name":"Prisoners","Type":"Movie","Genres":["Thriller"],
|
||||
"People":[{"Name":"Denis Villeneuve","Type":"Director"}]
|
||||
}`)
|
||||
|
||||
reasons := Why(profile, candidate, ReasonLimit)
|
||||
|
||||
if len(reasons) == 0 || reasons[0] != "Because you watch Thriller" {
|
||||
t.Fatalf("expected the genre reason first, got %v", reasons)
|
||||
}
|
||||
if !strings.Contains(strings.Join(reasons, "|"), "Denis Villeneuve") {
|
||||
t.Fatalf("expected the shared director to be named, got %v", reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhyNeverClaimsAnAffinityThatIsNotThere(t *testing.T) {
|
||||
profile := explainProfile(t, thrillerHistory)
|
||||
candidate := explainItem(t, `{
|
||||
"Id":"c2","Name":"Paddington","Type":"Movie","Genres":["Family"],
|
||||
"CommunityRating":8.2,
|
||||
"People":[{"Name":"Paul King","Type":"Director"}]
|
||||
}`)
|
||||
|
||||
reasons := Why(profile, candidate, ReasonLimit)
|
||||
|
||||
for _, reason := range reasons {
|
||||
if strings.HasPrefix(reason, "Because you watch") ||
|
||||
strings.HasPrefix(reason, "You've watched") {
|
||||
t.Fatalf("invented a taste the profile does not hold: %v", reasons)
|
||||
}
|
||||
}
|
||||
if len(reasons) == 0 || reasons[0] != "Well rated (8.2)" {
|
||||
t.Fatalf("expected the catalogue fact to carry the strip, got %v", reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhyAlwaysSaysSomething(t *testing.T) {
|
||||
candidate := explainItem(t, `{"Id":"c3","Name":"Unknown","Type":"Movie","Genres":["Drama"]}`)
|
||||
|
||||
reasons := Why(Profile{}, candidate, ReasonLimit)
|
||||
|
||||
if len(reasons) != 1 || reasons[0] != "Drama from your library" {
|
||||
t.Fatalf("an empty profile should still explain the title, got %v", reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhyIsCappedAndOrdered(t *testing.T) {
|
||||
// A second, Thriller-only title so Thriller genuinely outweighs Crime — with one
|
||||
// history item they tie and the alphabetical tie-break would decide it.
|
||||
profile := explainProfile(t, thrillerHistory, `{
|
||||
"Id":"h2","Name":"Nightcrawler","Type":"Movie","Genres":["Thriller"],
|
||||
"Studios":[{"Name":"Lionsgate"}],
|
||||
"People":[{"Name":"Emily Blunt","Type":"Actor"}]
|
||||
}`)
|
||||
candidate := explainItem(t, `{
|
||||
"Id":"c4","Name":"Wind River","Type":"Movie","Genres":["Thriller","Crime"],
|
||||
"Studios":[{"Name":"Lionsgate"}],"CommunityRating":9.1,"ProductionYear":2017,
|
||||
"People":[{"Name":"Emily Blunt","Type":"Actor"}]
|
||||
}`)
|
||||
|
||||
reasons := Why(profile, candidate, ReasonLimit)
|
||||
|
||||
if len(reasons) != ReasonLimit {
|
||||
t.Fatalf("expected exactly %d reasons, got %v", ReasonLimit, reasons)
|
||||
}
|
||||
want := []string{
|
||||
"Because you watch Thriller",
|
||||
"You've watched Emily Blunt before",
|
||||
"More from Lionsgate",
|
||||
}
|
||||
for i, reason := range want {
|
||||
if reasons[i] != reason {
|
||||
t.Fatalf("reason %d: want %q, got %q (%v)", i, reason, reasons[i], reasons)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhyIsStableAcrossCalls(t *testing.T) {
|
||||
profile := explainProfile(t, thrillerHistory)
|
||||
candidate := explainItem(t, `{
|
||||
"Id":"c5","Name":"Hell or High Water","Type":"Movie","Genres":["Crime","Thriller"]
|
||||
}`)
|
||||
|
||||
first := Why(profile, candidate, ReasonLimit)
|
||||
for i := 0; i < 20; i++ {
|
||||
if got := Why(profile, candidate, ReasonLimit); !equalStrings(first, got) {
|
||||
t.Fatalf("reasons changed between calls: %v then %v", first, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -49,7 +49,9 @@ type PreparedProfile struct {
|
||||
GenreAffinity map[string]float64
|
||||
TitleAffinity map[string]PreparedTitleAffinity
|
||||
StudioAffinity map[string]float64
|
||||
ContextAffinity ContextAffinityProfile
|
||||
CodecOutcomes map[string]map[string]int
|
||||
Weighted WeightedProfile
|
||||
SignalsThrough *time.Time
|
||||
}
|
||||
|
||||
@@ -77,6 +79,8 @@ type PreparedResult struct {
|
||||
|
||||
var ErrPreparedLibraryUnavailable = errors.New("recommend: prepared library unavailable")
|
||||
|
||||
const maxPreparedCandidatePool = 750
|
||||
|
||||
// PrepareForYou performs the expensive work outside a television request. It consumes
|
||||
// locally imported Tracearr sessions and the complete imported catalogue, producing a
|
||||
// compact profile and an intentionally over-provisioned ranked pool.
|
||||
@@ -167,6 +171,26 @@ func (e *Engine) PrepareForYou(
|
||||
tracearrUserID := ""
|
||||
tracearrUsername := strings.TrimSpace(username)
|
||||
titleSignalCount := map[string]int{}
|
||||
contextAffinity := NewContextAffinityProfile()
|
||||
weightedEvidence := make([]ViewingEvidence, 0, len(history)+len(favorites)+len(sessions))
|
||||
for _, item := range history {
|
||||
item = index.evidenceItem(item)
|
||||
completion := 0.0
|
||||
if item.UserData.Played {
|
||||
completion = 1
|
||||
} else if item.RunTimeTicks > 0 {
|
||||
completion = float64(item.UserData.PlaybackPositionTicks) / float64(item.RunTimeTicks)
|
||||
}
|
||||
weightedEvidence = append(weightedEvidence, ViewingEvidence{
|
||||
Item: item, Completion: completion, Repeat: maxInt(1, item.UserData.PlayCount),
|
||||
})
|
||||
}
|
||||
for _, item := range favorites {
|
||||
item = index.evidenceItem(item)
|
||||
weightedEvidence = append(weightedEvidence, ViewingEvidence{
|
||||
Item: item, Completion: 0, Favorite: true,
|
||||
})
|
||||
}
|
||||
|
||||
for i, session := range sessions {
|
||||
completion := session.Completion()
|
||||
@@ -225,6 +249,18 @@ func (e *Engine) PrepareForYou(
|
||||
current.SessionID = session.ID
|
||||
}
|
||||
titleAffinity[item.ID] = current
|
||||
if started, ok := parseTracearrTime(session.StartedAt); ok {
|
||||
contextAffinity.Add(item, started, completion, i, e.Location)
|
||||
weightedEvidence = append(weightedEvidence, ViewingEvidence{
|
||||
Item: item, Completion: completion, Repeat: repeats + 1,
|
||||
OccurredAt: started, SessionMinutes: int(int64(session.DurationMs) / 60_000),
|
||||
})
|
||||
} else {
|
||||
weightedEvidence = append(weightedEvidence, ViewingEvidence{
|
||||
Item: item, Completion: completion, Repeat: repeats + 1,
|
||||
SessionMinutes: int(int64(session.DurationMs) / 60_000),
|
||||
})
|
||||
}
|
||||
|
||||
if completion >= 0.9 {
|
||||
addCompletedEvidence(item, session.ID)
|
||||
@@ -262,9 +298,12 @@ func (e *Engine) PrepareForYou(
|
||||
return ranked[i].item.Name < ranked[j].item.Name
|
||||
})
|
||||
|
||||
prepared := make([]PreparedCandidate, 0, len(ranked))
|
||||
prepared := make([]PreparedCandidate, 0, min(len(ranked), maxPreparedCandidatePool))
|
||||
completedReasonCounts := map[string]int{}
|
||||
for rank, entry := range ranked {
|
||||
if len(prepared) == maxPreparedCandidatePool {
|
||||
break
|
||||
}
|
||||
reason, label, kind, genre, evidence := explainPreparedRecommendation(
|
||||
profile, entry.item, compatibility, browsed[entry.item.ID], evidenceByGenre,
|
||||
completedReasonCounts,
|
||||
@@ -294,6 +333,9 @@ func (e *Engine) PrepareForYou(
|
||||
pickups[i].BaseRank = i + 1
|
||||
}
|
||||
prepared = append(pickups, prepared...)
|
||||
if len(prepared) > maxPreparedCandidatePool {
|
||||
prepared = prepared[:maxPreparedCandidatePool]
|
||||
}
|
||||
}
|
||||
|
||||
meanCompletion := 0.0
|
||||
@@ -310,7 +352,11 @@ func (e *Engine) PrepareForYou(
|
||||
SourceSessionCount: len(sessions), MeanCompletionRatio: meanCompletion,
|
||||
TypicalSessionMinutes: medianInt(durations),
|
||||
GenreAffinity: profile.GenreWeights, TitleAffinity: titleAffinity,
|
||||
StudioAffinity: profile.StudioWeights, CodecOutcomes: codecs,
|
||||
StudioAffinity: profile.StudioWeights, ContextAffinity: contextAffinity,
|
||||
CodecOutcomes: codecs,
|
||||
Weighted: BuildWeightedProfileWithConfig(
|
||||
weightedEvidence, time.Now(), e.Location, e.WeightedConfig,
|
||||
),
|
||||
SignalsThrough: signalsThrough,
|
||||
},
|
||||
Candidates: prepared,
|
||||
@@ -390,9 +436,11 @@ func abandonedShowCandidates(
|
||||
}
|
||||
if activity.After(current.lastActivity) {
|
||||
current.lastActivity = activity
|
||||
if session.SeasonNumber != nil {
|
||||
current.lastSeason = *session.SeasonNumber
|
||||
}
|
||||
}
|
||||
// Track the furthest season ever reached, not merely the season from the most
|
||||
// recent replay. Any season-two evidence disqualifies a first-season pickup.
|
||||
if session.SeasonNumber != nil && *session.SeasonNumber > current.lastSeason {
|
||||
current.lastSeason = *session.SeasonNumber
|
||||
}
|
||||
if session.Completion() >= 0.9 && session.SeasonNumber != nil &&
|
||||
session.EpisodeNumber != nil {
|
||||
@@ -420,7 +468,14 @@ func abandonedShowCandidates(
|
||||
cutoff := now.Add(-abandonedShowAge)
|
||||
for seriesID, watched := range progress {
|
||||
next, unfinished := nextBySeries[seriesID]
|
||||
if !unfinished || watched.lastActivity.After(cutoff) {
|
||||
// This shelf is intentionally about promising shows abandoned in or just after
|
||||
// their first season. Later-season lapses are ordinary Next Up material. Some
|
||||
// Tracearr episode records lack a season number, so a season-one Next Up is also
|
||||
// sufficient evidence that the viewer is still at the beginning.
|
||||
firstSeasonAbandonment := watched.lastSeason == 1 &&
|
||||
next.ParentIndexNumber <= 2 ||
|
||||
watched.lastSeason == 0 && next.ParentIndexNumber == 1
|
||||
if !unfinished || watched.lastActivity.After(cutoff) || !firstSeasonAbandonment {
|
||||
continue
|
||||
}
|
||||
eligible = append(eligible, pickup{progress: watched, next: next})
|
||||
@@ -478,7 +533,7 @@ func abandonedShowReason(lastSeason, nextSeason int) string {
|
||||
return fmt.Sprintf("You made it through season %d · season %d is waiting", lastSeason, nextSeason)
|
||||
case lastSeason > 1:
|
||||
return fmt.Sprintf("You made it to season %d · pick it up again", lastSeason)
|
||||
case lastSeason == 1:
|
||||
case lastSeason == 1 || lastSeason == 0 && nextSeason == 1:
|
||||
return "You left this in season 1 · pick it up again"
|
||||
default:
|
||||
return "You left this unfinished · pick it up again"
|
||||
@@ -596,15 +651,17 @@ type catalogueIndex struct {
|
||||
movieExact map[string]Item
|
||||
movieLoose map[string]Item
|
||||
series map[string]Item
|
||||
byID map[string]Item
|
||||
ambiguous map[string]bool
|
||||
}
|
||||
|
||||
func newCatalogueIndex(items []Item) catalogueIndex {
|
||||
index := catalogueIndex{
|
||||
movieExact: map[string]Item{}, movieLoose: map[string]Item{},
|
||||
series: map[string]Item{}, ambiguous: map[string]bool{},
|
||||
series: map[string]Item{}, byID: map[string]Item{}, ambiguous: map[string]bool{},
|
||||
}
|
||||
for _, item := range items {
|
||||
index.byID[item.ID] = item
|
||||
key := normalizePreparedTitle(item.Name)
|
||||
switch item.Type {
|
||||
case "Movie":
|
||||
@@ -627,6 +684,21 @@ func newCatalogueIndex(items []Item) catalogueIndex {
|
||||
return index
|
||||
}
|
||||
|
||||
// evidenceItem promotes episode evidence to its series metadata. Emby episode rows often
|
||||
// omit People and studio details even when those fields were requested, while the parent
|
||||
// Series record contains the canonical cast and production metadata.
|
||||
func (i catalogueIndex) evidenceItem(item Item) Item {
|
||||
if !strings.EqualFold(item.Type, "Episode") || item.SeriesID == "" {
|
||||
return item
|
||||
}
|
||||
parent, ok := i.byID[item.SeriesID]
|
||||
if !ok {
|
||||
return item
|
||||
}
|
||||
parent.UserData = item.UserData
|
||||
return parent
|
||||
}
|
||||
|
||||
func (i catalogueIndex) match(session tracearr.Session) (Item, bool) {
|
||||
if strings.EqualFold(session.MediaType, "episode") && strings.TrimSpace(session.ShowTitle) != "" {
|
||||
key := normalizePreparedTitle(session.ShowTitle)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
@@ -37,6 +38,10 @@ type Item struct {
|
||||
Genres []string `json:"Genres"`
|
||||
CommunityRating float64 `json:"CommunityRating"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
OfficialRating string `json:"OfficialRating"`
|
||||
CollectionName string `json:"CollectionName"`
|
||||
PremiereDate string `json:"PremiereDate"`
|
||||
DateCreated string `json:"DateCreated"`
|
||||
IndexNumber int `json:"IndexNumber"`
|
||||
ParentIndexNumber int `json:"ParentIndexNumber"`
|
||||
Container string `json:"Container"`
|
||||
@@ -47,6 +52,7 @@ type Item struct {
|
||||
Studios []struct {
|
||||
Name string `json:"Name"`
|
||||
} `json:"Studios"`
|
||||
People []Person `json:"People"`
|
||||
UserData struct {
|
||||
Played bool `json:"Played"`
|
||||
PlayCount int `json:"PlayCount"`
|
||||
@@ -99,6 +105,10 @@ type Seed struct {
|
||||
type Profile struct {
|
||||
GenreWeights map[string]float64
|
||||
StudioWeights map[string]float64
|
||||
// PersonWeights is read only by the explanation layer, never by Score. Casting is a
|
||||
// good reason to *tell* someone about a title and a poor reason to rank by it: two
|
||||
// films sharing an actor are often nothing alike.
|
||||
PersonWeights map[string]float64
|
||||
// Seen holds item ids *and* series ids already watched or in progress, so a
|
||||
// recommendation never suggests something the user is already partway through.
|
||||
Seen map[string]bool
|
||||
@@ -130,6 +140,7 @@ func BuildProfile(history, favorites []Item) Profile {
|
||||
profile := Profile{
|
||||
GenreWeights: map[string]float64{},
|
||||
StudioWeights: map[string]float64{},
|
||||
PersonWeights: map[string]float64{},
|
||||
Seen: map[string]bool{},
|
||||
SeenTitles: map[string]bool{},
|
||||
}
|
||||
@@ -201,6 +212,27 @@ func (p *Profile) absorbTaste(item Item, weight float64) {
|
||||
p.StudioWeights[s] += weight * 0.4
|
||||
}
|
||||
}
|
||||
for _, person := range item.People {
|
||||
if !isExplainablePerson(person.Type) {
|
||||
continue
|
||||
}
|
||||
if name := strings.TrimSpace(person.Name); name != "" {
|
||||
if p.PersonWeights == nil {
|
||||
p.PersonWeights = map[string]float64{}
|
||||
}
|
||||
p.PersonWeights[name] += weight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isExplainablePerson keeps the cast list down to the roles a viewer would recognise as
|
||||
// a reason. A gaffer in common is not why anyone picks a film.
|
||||
func isExplainablePerson(role string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "actor", "director", "writer":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TopGenres returns the n heaviest genres, highest first. Ties break alphabetically so
|
||||
@@ -263,7 +295,20 @@ func (p Profile) Score(candidate Item) float64 {
|
||||
// A mild quality nudge, capped so a beloved genre still beats a well-rated stranger.
|
||||
ratingScore := candidate.CommunityRating / 10 * 0.5
|
||||
|
||||
return genreScore + studioScore + ratingScore
|
||||
// New catalogue arrivals should surface without overpowering established taste.
|
||||
// Production year is consistently available from both the imported library and
|
||||
// Emby, unlike DateCreated on deliberately narrow recommendation payloads.
|
||||
var freshnessScore float64
|
||||
age := time.Now().Year() - candidate.ProductionYear
|
||||
switch {
|
||||
case candidate.ProductionYear <= 0:
|
||||
case age <= 1:
|
||||
freshnessScore = 0.25
|
||||
case age <= 3:
|
||||
freshnessScore = 0.12
|
||||
}
|
||||
|
||||
return genreScore + studioScore + ratingScore + freshnessScore
|
||||
}
|
||||
|
||||
// CollectionAffinity decides which curated shelf appears first for this user.
|
||||
|
||||
@@ -0,0 +1,722 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"hash/fnv"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WeightedConfig is intentionally data, not code: operators can tune the algorithm
|
||||
// without changing its shape or retraining an opaque model.
|
||||
type WeightedConfig struct {
|
||||
MinimumEvidence int
|
||||
ExplorationRate float64
|
||||
MaxPrimaryGenre int
|
||||
MaxLeadPerson int
|
||||
NewReleaseDays int
|
||||
ImpressionFloor int
|
||||
ImpressionPenalty float64
|
||||
IgnoredPenalty float64
|
||||
CompletionWeight float64
|
||||
AbandonmentWeight float64
|
||||
RecencyHalfLifeDays float64
|
||||
CommunityPriorWeight float64
|
||||
HouseholdPriorWeight float64
|
||||
CompatibilityWeight float64
|
||||
ContextWeight float64
|
||||
RuntimeContextWeight float64
|
||||
ExplicitPositiveBoost float64
|
||||
ExplicitNegativeScore float64
|
||||
}
|
||||
|
||||
func DefaultWeightedConfig() WeightedConfig {
|
||||
return WeightedConfig{
|
||||
MinimumEvidence: 2, ExplorationRate: 0.08,
|
||||
MaxPrimaryGenre: 3, MaxLeadPerson: 2, NewReleaseDays: 180,
|
||||
ImpressionFloor: 3, ImpressionPenalty: 0.12, IgnoredPenalty: 0.28,
|
||||
CompletionWeight: 1, AbandonmentWeight: 0.55, RecencyHalfLifeDays: 45,
|
||||
CommunityPriorWeight: 0.35, HouseholdPriorWeight: 0.45,
|
||||
CompatibilityWeight: 0.7, ContextWeight: 0.8, RuntimeContextWeight: 0.65,
|
||||
ExplicitPositiveBoost: 3.5, ExplicitNegativeScore: -1_000,
|
||||
}
|
||||
}
|
||||
|
||||
type Person struct {
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
Role string `json:"Role"`
|
||||
}
|
||||
|
||||
// Affinity retains its evidence count so a single accidental play cannot silently
|
||||
// become a durable preference.
|
||||
type Affinity struct {
|
||||
Weight float64 `json:"weight"`
|
||||
Evidence int `json:"evidence"`
|
||||
}
|
||||
|
||||
type WeightedProfile struct {
|
||||
Genres map[string]Affinity `json:"genres,omitempty"`
|
||||
Studios map[string]Affinity `json:"studios,omitempty"`
|
||||
Actors map[string]Affinity `json:"actors,omitempty"`
|
||||
Directors map[string]Affinity `json:"directors,omitempty"`
|
||||
Franchises map[string]Affinity `json:"franchises,omitempty"`
|
||||
RuntimeRanges map[string]Affinity `json:"runtimeRanges,omitempty"`
|
||||
AgeRatings map[string]Affinity `json:"ageRatings,omitempty"`
|
||||
CommunityRatings map[string]Affinity `json:"communityRatings,omitempty"`
|
||||
ReleasePeriods map[string]Affinity `json:"releasePeriods,omitempty"`
|
||||
ContentTypes map[string]Affinity `json:"contentTypes,omitempty"`
|
||||
|
||||
Seen map[string]bool `json:"seen,omitempty"`
|
||||
ExplicitPositive map[string]bool `json:"explicitPositive,omitempty"`
|
||||
ExplicitNegative map[string]bool `json:"explicitNegative,omitempty"`
|
||||
TypicalSessionMins map[string]float64 `json:"typicalSessionMinutes,omitempty"`
|
||||
SessionEvidence map[string]int `json:"sessionEvidence,omitempty"`
|
||||
SourceEvents int `json:"sourceEvents"`
|
||||
}
|
||||
|
||||
type ViewingEvidence struct {
|
||||
Item Item
|
||||
Completion float64
|
||||
Repeat int
|
||||
OccurredAt time.Time
|
||||
SessionMinutes int
|
||||
Favorite bool
|
||||
}
|
||||
|
||||
type OnboardingPreferences struct {
|
||||
Completed bool `json:"completed"`
|
||||
Ratings map[string]int `json:"ratings,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
Studios []string `json:"studios,omitempty"`
|
||||
Actors []string `json:"actors,omitempty"`
|
||||
Directors []string `json:"directors,omitempty"`
|
||||
ContentTypes []string `json:"contentTypes,omitempty"`
|
||||
}
|
||||
|
||||
func (p *WeightedProfile) ApplyOnboarding(preferences OnboardingPreferences, minimumEvidence int) {
|
||||
p.ensureAffinityMaps()
|
||||
if minimumEvidence < 1 {
|
||||
minimumEvidence = 1
|
||||
}
|
||||
add := func(target map[string]Affinity, values []string) {
|
||||
for _, key := range values {
|
||||
key = normalizeDimension(key)
|
||||
if key != "" {
|
||||
target[key] = Affinity{Weight: 0.8, Evidence: minimumEvidence}
|
||||
}
|
||||
}
|
||||
}
|
||||
add(p.Genres, preferences.Genres)
|
||||
add(p.Studios, preferences.Studios)
|
||||
add(p.Actors, preferences.Actors)
|
||||
add(p.Directors, preferences.Directors)
|
||||
add(p.ContentTypes, preferences.ContentTypes)
|
||||
}
|
||||
|
||||
// ApplyOnboardingRating turns a deliberate 1–5 title rating into immediate profile
|
||||
// evidence. Unlike an accidental play, an explicit rating is trusted enough to satisfy
|
||||
// MinimumEvidence on its own.
|
||||
func (p *WeightedProfile) ApplyOnboardingRating(item Item, rating, minimumEvidence int) {
|
||||
p.ensureAffinityMaps()
|
||||
if p.Seen == nil {
|
||||
p.Seen = map[string]bool{}
|
||||
}
|
||||
if item.ID != "" {
|
||||
p.Seen[item.ID] = true
|
||||
}
|
||||
if item.SeriesID != "" {
|
||||
p.Seen[item.SeriesID] = true
|
||||
}
|
||||
if rating < 1 || rating > 5 || rating == 3 {
|
||||
return
|
||||
}
|
||||
if minimumEvidence < 1 {
|
||||
minimumEvidence = 1
|
||||
}
|
||||
// Two stars either side of neutral maps to a strong but bounded ±2.4 signal.
|
||||
total := float64(rating-3) * 1.2
|
||||
for range minimumEvidence {
|
||||
addItemAffinities(p, item, total/float64(minimumEvidence))
|
||||
}
|
||||
}
|
||||
|
||||
// BuildWeightedProfile treats Tracearr/Emby events as evidence. Completion, repetition
|
||||
// and recency affect strength, while Affinity.Evidence enforces the repeated-pattern
|
||||
// threshold at scoring time.
|
||||
func BuildWeightedProfile(events []ViewingEvidence, now time.Time, location *time.Location) WeightedProfile {
|
||||
return BuildWeightedProfileWithConfig(events, now, location, DefaultWeightedConfig())
|
||||
}
|
||||
|
||||
func BuildWeightedProfileWithConfig(
|
||||
events []ViewingEvidence,
|
||||
now time.Time,
|
||||
location *time.Location,
|
||||
cfg WeightedConfig,
|
||||
) WeightedProfile {
|
||||
if cfg.MinimumEvidence < 1 {
|
||||
cfg = DefaultWeightedConfig()
|
||||
}
|
||||
p := WeightedProfile{
|
||||
Genres: map[string]Affinity{}, Studios: map[string]Affinity{},
|
||||
Actors: map[string]Affinity{}, Directors: map[string]Affinity{},
|
||||
Franchises: map[string]Affinity{}, RuntimeRanges: map[string]Affinity{},
|
||||
AgeRatings: map[string]Affinity{}, CommunityRatings: map[string]Affinity{},
|
||||
ReleasePeriods: map[string]Affinity{},
|
||||
ContentTypes: map[string]Affinity{}, Seen: map[string]bool{},
|
||||
ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{},
|
||||
TypicalSessionMins: map[string]float64{}, SessionEvidence: map[string]int{},
|
||||
}
|
||||
if location == nil {
|
||||
location = time.Local
|
||||
}
|
||||
sessionTotals := map[string]float64{}
|
||||
for _, event := range events {
|
||||
if event.Item.ID == "" {
|
||||
continue
|
||||
}
|
||||
p.SourceEvents++
|
||||
p.Seen[event.Item.ID] = event.Completion > 0
|
||||
if event.Item.SeriesID != "" && event.Completion > 0 {
|
||||
p.Seen[event.Item.SeriesID] = true
|
||||
}
|
||||
completion := clamp01(event.Completion)
|
||||
strength := evidenceStrength(completion, cfg)
|
||||
if !event.OccurredAt.IsZero() {
|
||||
ageDays := math.Max(0, now.Sub(event.OccurredAt).Hours()/24)
|
||||
strength *= math.Pow(0.5, ageDays/math.Max(1, cfg.RecencyHalfLifeDays))
|
||||
}
|
||||
if event.Repeat > 1 {
|
||||
strength *= 1 + math.Min(1.2, math.Log2(float64(event.Repeat))*0.45)
|
||||
}
|
||||
if event.Favorite {
|
||||
strength += 0.8
|
||||
p.ExplicitPositive[event.Item.ID] = true
|
||||
}
|
||||
addItemAffinities(&p, event.Item, strength)
|
||||
if event.SessionMinutes > 0 && !event.OccurredAt.IsZero() {
|
||||
slot := contextSlotKey(event.OccurredAt.In(location).Weekday(), dayPart(event.OccurredAt.In(location).Hour()))
|
||||
sessionTotals[slot] += float64(event.SessionMinutes)
|
||||
p.SessionEvidence[slot]++
|
||||
}
|
||||
}
|
||||
for slot, total := range sessionTotals {
|
||||
p.TypicalSessionMins[slot] = total / float64(p.SessionEvidence[slot])
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func evidenceStrength(completion float64, cfg WeightedConfig) float64 {
|
||||
switch {
|
||||
case completion >= 0.9:
|
||||
return cfg.CompletionWeight
|
||||
case completion >= 0.5:
|
||||
return cfg.CompletionWeight * 0.45
|
||||
case completion >= 0.15:
|
||||
return -cfg.AbandonmentWeight
|
||||
default:
|
||||
// A very brief start is weak evidence, not a strong dislike.
|
||||
return -0.08
|
||||
}
|
||||
}
|
||||
|
||||
func addItemAffinities(p *WeightedProfile, item Item, weight float64) {
|
||||
for _, value := range item.Genres {
|
||||
addAffinity(p.Genres, value, weight)
|
||||
}
|
||||
for _, value := range item.Studios {
|
||||
addAffinity(p.Studios, value.Name, weight)
|
||||
}
|
||||
for _, person := range item.People {
|
||||
switch strings.ToLower(strings.TrimSpace(person.Type)) {
|
||||
case "actor":
|
||||
addAffinity(p.Actors, person.Name, weight*0.65)
|
||||
case "director":
|
||||
addAffinity(p.Directors, person.Name, weight*0.8)
|
||||
}
|
||||
}
|
||||
addAffinity(p.Franchises, item.Franchise(), weight*0.85)
|
||||
addAffinity(p.RuntimeRanges, runtimeRange(item.RuntimeMinutes()), weight*0.55)
|
||||
addAffinity(p.AgeRatings, item.OfficialRating, weight*0.45)
|
||||
addAffinity(p.CommunityRatings, communityRatingRange(item.CommunityRating), weight*0.35)
|
||||
addAffinity(p.ReleasePeriods, releasePeriod(item.ProductionYear), weight*0.5)
|
||||
addAffinity(p.ContentTypes, item.Type, weight*0.6)
|
||||
}
|
||||
|
||||
// ApplyExplicitPreference lets More Like This / Not for Me influence adjacent titles.
|
||||
// The item itself is always boosted/excluded; metadata still needs repeated negative
|
||||
// actions before it becomes a broader dislike because normal minimum-evidence rules
|
||||
// remain in force.
|
||||
func (p *WeightedProfile) ApplyExplicitPreference(item Item, positive bool) {
|
||||
p.ensureAffinityMaps()
|
||||
if p.ExplicitPositive == nil {
|
||||
p.ExplicitPositive = map[string]bool{}
|
||||
}
|
||||
if p.ExplicitNegative == nil {
|
||||
p.ExplicitNegative = map[string]bool{}
|
||||
}
|
||||
if positive {
|
||||
p.ExplicitPositive[item.ID] = true
|
||||
addItemAffinities(p, item, 1.5)
|
||||
return
|
||||
}
|
||||
p.ExplicitNegative[item.ID] = true
|
||||
addItemAffinities(p, item, -1.2)
|
||||
}
|
||||
|
||||
func (p *WeightedProfile) ensureAffinityMaps() {
|
||||
if p.Genres == nil {
|
||||
p.Genres = map[string]Affinity{}
|
||||
}
|
||||
if p.Studios == nil {
|
||||
p.Studios = map[string]Affinity{}
|
||||
}
|
||||
if p.Actors == nil {
|
||||
p.Actors = map[string]Affinity{}
|
||||
}
|
||||
if p.Directors == nil {
|
||||
p.Directors = map[string]Affinity{}
|
||||
}
|
||||
if p.Franchises == nil {
|
||||
p.Franchises = map[string]Affinity{}
|
||||
}
|
||||
if p.RuntimeRanges == nil {
|
||||
p.RuntimeRanges = map[string]Affinity{}
|
||||
}
|
||||
if p.AgeRatings == nil {
|
||||
p.AgeRatings = map[string]Affinity{}
|
||||
}
|
||||
if p.CommunityRatings == nil {
|
||||
p.CommunityRatings = map[string]Affinity{}
|
||||
}
|
||||
if p.ReleasePeriods == nil {
|
||||
p.ReleasePeriods = map[string]Affinity{}
|
||||
}
|
||||
if p.ContentTypes == nil {
|
||||
p.ContentTypes = map[string]Affinity{}
|
||||
}
|
||||
}
|
||||
|
||||
func addAffinity(values map[string]Affinity, key string, weight float64) {
|
||||
key = normalizeDimension(key)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
value := values[key]
|
||||
value.Weight += weight
|
||||
value.Evidence++
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
type ItemExposure struct {
|
||||
Impressions int `json:"impressions"`
|
||||
Focuses int `json:"focuses"`
|
||||
Selects int `json:"selects"`
|
||||
LastShown time.Time `json:"lastShown,omitempty"`
|
||||
}
|
||||
|
||||
type RankIntent struct {
|
||||
ID string
|
||||
ItemTypes []string
|
||||
UnseenOnly bool
|
||||
NewReleasesOnly bool
|
||||
MaxRuntimeMins int
|
||||
PreferShort bool
|
||||
HiddenLibrary bool
|
||||
SearchRelevance map[string]float64
|
||||
HouseholdScores map[string]float64
|
||||
Compatibility map[string]float64
|
||||
Now time.Time
|
||||
Location *time.Location
|
||||
}
|
||||
|
||||
type ScoreExplanation struct {
|
||||
Total float64 `json:"total"`
|
||||
Components map[string]float64 `json:"components"`
|
||||
Reasons []string `json:"reasonCodes"`
|
||||
Exploration bool `json:"exploration,omitempty"`
|
||||
}
|
||||
|
||||
type RankedItem struct {
|
||||
Item Item
|
||||
Explanation ScoreExplanation
|
||||
}
|
||||
|
||||
// WeightedRank applies the same scoring foundation to any page or row. RankIntent only
|
||||
// changes eligibility and emphasis; it never creates a separate recommendation model.
|
||||
func WeightedRank(
|
||||
profile WeightedProfile,
|
||||
candidates []Item,
|
||||
exposures map[string]ItemExposure,
|
||||
intent RankIntent,
|
||||
cfg WeightedConfig,
|
||||
limit int,
|
||||
) []RankedItem {
|
||||
if cfg.MinimumEvidence < 1 {
|
||||
cfg = DefaultWeightedConfig()
|
||||
}
|
||||
now := intent.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
type scored struct {
|
||||
item Item
|
||||
exp ScoreExplanation
|
||||
}
|
||||
values := make([]scored, 0, len(candidates))
|
||||
seenIDs := map[string]bool{}
|
||||
for _, item := range candidates {
|
||||
if item.ID == "" || seenIDs[item.ID] || !eligibleForIntent(profile, item, intent, cfg, now) {
|
||||
continue
|
||||
}
|
||||
seenIDs[item.ID] = true
|
||||
exp := scoreWeightedItem(profile, item, exposures[item.ID], intent, cfg, now)
|
||||
if exp.Total <= cfg.ExplicitNegativeScore/2 {
|
||||
continue
|
||||
}
|
||||
values = append(values, scored{item: item, exp: exp})
|
||||
}
|
||||
sort.SliceStable(values, func(i, j int) bool {
|
||||
if values[i].exp.Total != values[j].exp.Total {
|
||||
return values[i].exp.Total > values[j].exp.Total
|
||||
}
|
||||
return values[i].item.Name < values[j].item.Name
|
||||
})
|
||||
|
||||
out := make([]RankedItem, 0, minPositive(limit, len(values)))
|
||||
genreCounts, peopleCounts := map[string]int{}, map[string]int{}
|
||||
deferred := make([]scored, 0)
|
||||
for _, value := range values {
|
||||
genre := primaryGenre(value.item)
|
||||
person := leadPerson(value.item)
|
||||
if cfg.MaxPrimaryGenre > 0 && genre != "" && genreCounts[genre] >= cfg.MaxPrimaryGenre ||
|
||||
cfg.MaxLeadPerson > 0 && person != "" && peopleCounts[person] >= cfg.MaxLeadPerson {
|
||||
deferred = append(deferred, value)
|
||||
continue
|
||||
}
|
||||
out = append(out, RankedItem{Item: value.item, Explanation: value.exp})
|
||||
genreCounts[genre]++
|
||||
peopleCounts[person]++
|
||||
if limit > 0 && len(out) == limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, value := range deferred {
|
||||
if limit > 0 && len(out) == limit {
|
||||
break
|
||||
}
|
||||
out = append(out, RankedItem{Item: value.item, Explanation: value.exp})
|
||||
}
|
||||
applyExploration(out, cfg.ExplorationRate)
|
||||
return out
|
||||
}
|
||||
|
||||
func eligibleForIntent(
|
||||
profile WeightedProfile,
|
||||
item Item,
|
||||
intent RankIntent,
|
||||
cfg WeightedConfig,
|
||||
now time.Time,
|
||||
) bool {
|
||||
if profile.ExplicitNegative[item.ID] {
|
||||
return false
|
||||
}
|
||||
if intent.UnseenOnly && (profile.Seen[item.ID] || item.UserData.Played ||
|
||||
item.UserData.PlaybackPositionTicks > 0) {
|
||||
return false
|
||||
}
|
||||
if len(intent.ItemTypes) > 0 && !containsFold(intent.ItemTypes, item.Type) {
|
||||
return false
|
||||
}
|
||||
if intent.MaxRuntimeMins > 0 && item.RuntimeMinutes() > intent.MaxRuntimeMins {
|
||||
return false
|
||||
}
|
||||
if intent.NewReleasesOnly {
|
||||
released, ok := item.ReleaseDate()
|
||||
if !ok || released.After(now) || released.Before(now.AddDate(0, 0, -cfg.NewReleaseDays)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func scoreWeightedItem(
|
||||
profile WeightedProfile,
|
||||
item Item,
|
||||
exposure ItemExposure,
|
||||
intent RankIntent,
|
||||
cfg WeightedConfig,
|
||||
now time.Time,
|
||||
) ScoreExplanation {
|
||||
c := map[string]float64{}
|
||||
reasons := []string{}
|
||||
c["genre"] = affinitySum(profile.Genres, item.Genres, cfg.MinimumEvidence)
|
||||
c["studio"] = affinitySum(profile.Studios, studioNames(item), cfg.MinimumEvidence)
|
||||
c["actor"] = affinitySum(profile.Actors, peopleNames(item, "actor"), cfg.MinimumEvidence)
|
||||
c["director"] = affinitySum(profile.Directors, peopleNames(item, "director"), cfg.MinimumEvidence)
|
||||
c["franchise"] = affinitySum(profile.Franchises, []string{item.Franchise()}, cfg.MinimumEvidence)
|
||||
c["runtime"] = affinitySum(profile.RuntimeRanges, []string{runtimeRange(item.RuntimeMinutes())}, cfg.MinimumEvidence)
|
||||
c["ageRating"] = affinitySum(profile.AgeRatings, []string{item.OfficialRating}, cfg.MinimumEvidence)
|
||||
c["communityRatingAffinity"] = affinitySum(
|
||||
profile.CommunityRatings,
|
||||
[]string{communityRatingRange(item.CommunityRating)},
|
||||
cfg.MinimumEvidence,
|
||||
)
|
||||
c["releasePeriod"] = affinitySum(profile.ReleasePeriods, []string{releasePeriod(item.ProductionYear)}, cfg.MinimumEvidence)
|
||||
c["contentType"] = affinitySum(profile.ContentTypes, []string{item.Type}, cfg.MinimumEvidence)
|
||||
c["communityRating"] = item.CommunityRating / 10 * cfg.CommunityPriorWeight
|
||||
c["household"] = intent.HouseholdScores[item.ID] * cfg.HouseholdPriorWeight
|
||||
c["compatibility"] = intent.Compatibility[item.ID] * cfg.CompatibilityWeight
|
||||
c["searchRelevance"] = intent.SearchRelevance[item.ID]
|
||||
if profile.ExplicitPositive[item.ID] {
|
||||
c["explicit"] = cfg.ExplicitPositiveBoost
|
||||
reasons = append(reasons, "explicit_more_like_this")
|
||||
}
|
||||
if exposure.Impressions >= cfg.ImpressionFloor {
|
||||
ignored := maxInt(0, exposure.Impressions-exposure.Focuses-exposure.Selects)
|
||||
c["impressionFatigue"] = -float64(exposure.Impressions-cfg.ImpressionFloor+1)*cfg.ImpressionPenalty -
|
||||
float64(ignored)*cfg.IgnoredPenalty
|
||||
reasons = append(reasons, "impression_fatigue")
|
||||
}
|
||||
slot := currentContextSlot(now, intent.Location)
|
||||
if profile.SessionEvidence[slot] >= cfg.MinimumEvidence && item.RuntimeMinutes() > 0 {
|
||||
typical := profile.TypicalSessionMins[slot]
|
||||
delta := math.Abs(float64(item.RuntimeMinutes()) - typical)
|
||||
c["sessionFit"] = math.Max(-1, 1-delta/math.Max(20, typical)) * cfg.RuntimeContextWeight
|
||||
if c["sessionFit"] > 0.25 {
|
||||
reasons = append(reasons, "fits_session_length")
|
||||
}
|
||||
}
|
||||
if intent.PreferShort && item.RuntimeMinutes() > 0 {
|
||||
c["rowIntent"] = 1 / math.Max(1, float64(item.RuntimeMinutes())/30)
|
||||
}
|
||||
if intent.HiddenLibrary && !profile.Seen[item.ID] {
|
||||
c["rowIntent"] += 0.7
|
||||
reasons = append(reasons, "relevant_unseen")
|
||||
}
|
||||
for _, key := range []string{"genre", "studio", "actor", "director", "franchise"} {
|
||||
if c[key] > 0.1 {
|
||||
reasons = append(reasons, "affinity_"+strings.ToLower(key))
|
||||
}
|
||||
}
|
||||
if profile.SourceEvents < cfg.MinimumEvidence {
|
||||
reasons = append(reasons, "cold_start_priors")
|
||||
}
|
||||
total := 0.0
|
||||
for _, value := range c {
|
||||
total += value
|
||||
}
|
||||
return ScoreExplanation{Total: total, Components: c, Reasons: uniqueStrings(reasons)}
|
||||
}
|
||||
|
||||
// EnrichRankedItem keeps diagnostics on the backend response while retaining Emby's
|
||||
// original item contract.
|
||||
func EnrichRankedItem(item RankedItem) json.RawMessage {
|
||||
var payload map[string]any
|
||||
if json.Unmarshal(item.Item.Raw, &payload) != nil || payload == nil {
|
||||
payload = map[string]any{"Id": item.Item.ID, "Name": item.Item.Name, "Type": item.Item.Type}
|
||||
}
|
||||
payload["MembyRecommendationScore"] = item.Explanation.Total
|
||||
payload["MembyRecommendationComponents"] = item.Explanation.Components
|
||||
payload["MembyRecommendationReasonCodes"] = item.Explanation.Reasons
|
||||
payload["MembyExploration"] = item.Explanation.Exploration
|
||||
raw, _ := json.Marshal(payload)
|
||||
return raw
|
||||
}
|
||||
|
||||
func affinitySum(values map[string]Affinity, keys []string, minimum int) float64 {
|
||||
score := 0.0
|
||||
for _, key := range keys {
|
||||
value := values[normalizeDimension(key)]
|
||||
if value.Evidence >= minimum {
|
||||
score += value.Weight / math.Sqrt(float64(value.Evidence))
|
||||
}
|
||||
}
|
||||
if len(keys) > 1 {
|
||||
score /= math.Sqrt(float64(len(keys)))
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func applyExploration(items []RankedItem, rate float64) {
|
||||
if len(items) < 4 || rate <= 0 {
|
||||
return
|
||||
}
|
||||
count := int(math.Round(float64(len(items)) * math.Min(0.2, rate)))
|
||||
for n := 0; n < count; n++ {
|
||||
from := len(items) - 1 - n
|
||||
to := minPositive(3+n*5, from)
|
||||
if from <= to {
|
||||
continue
|
||||
}
|
||||
value := items[from]
|
||||
copy(items[to+1:from+1], items[to:from])
|
||||
value.Explanation.Exploration = true
|
||||
value.Explanation.Reasons = append(value.Explanation.Reasons, "adjacent_exploration")
|
||||
items[to] = value
|
||||
}
|
||||
}
|
||||
|
||||
func (i Item) Franchise() string {
|
||||
if value := strings.TrimSpace(i.CollectionName); value != "" {
|
||||
return value
|
||||
}
|
||||
// A conservative fallback only strips common sequel suffixes. It avoids inventing
|
||||
// franchises from unrelated titles that happen to share one word.
|
||||
parts := strings.Fields(i.Name)
|
||||
if len(parts) > 1 {
|
||||
last := strings.Trim(strings.ToLower(parts[len(parts)-1]), ":.-")
|
||||
if isRomanNumeral(last) || strings.HasPrefix(last, "part") {
|
||||
return strings.Join(parts[:len(parts)-1], " ")
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (i Item) ReleaseDate() (time.Time, bool) {
|
||||
for _, value := range []string{i.PremiereDate, i.DateCreated} {
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value)); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
if i.ProductionYear > 0 {
|
||||
return time.Date(i.ProductionYear, 1, 1, 0, 0, 0, 0, time.UTC), true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func runtimeRange(minutes int) string {
|
||||
switch {
|
||||
case minutes <= 0:
|
||||
return ""
|
||||
case minutes <= 25:
|
||||
return "short"
|
||||
case minutes <= 50:
|
||||
return "episode"
|
||||
case minutes <= 100:
|
||||
return "feature"
|
||||
case minutes <= 150:
|
||||
return "long-feature"
|
||||
default:
|
||||
return "epic"
|
||||
}
|
||||
}
|
||||
|
||||
func releasePeriod(year int) string {
|
||||
switch {
|
||||
case year <= 0:
|
||||
return ""
|
||||
case year < 1980:
|
||||
return "classic"
|
||||
case year < 2000:
|
||||
return "1980s-1990s"
|
||||
case year < 2015:
|
||||
return "2000s-early-2010s"
|
||||
default:
|
||||
return "recent"
|
||||
}
|
||||
}
|
||||
|
||||
func communityRatingRange(rating float64) string {
|
||||
switch {
|
||||
case rating <= 0:
|
||||
return ""
|
||||
case rating < 6:
|
||||
return "under-6"
|
||||
case rating < 7.5:
|
||||
return "6-to-7.4"
|
||||
case rating < 8.5:
|
||||
return "7.5-to-8.4"
|
||||
default:
|
||||
return "8.5-plus"
|
||||
}
|
||||
}
|
||||
|
||||
func currentContextSlot(now time.Time, location *time.Location) string {
|
||||
if location != nil {
|
||||
now = now.In(location)
|
||||
}
|
||||
return contextSlotKey(now.Weekday(), dayPart(now.Hour()))
|
||||
}
|
||||
|
||||
func normalizeDimension(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
|
||||
func studioNames(item Item) []string {
|
||||
out := make([]string, 0, len(item.Studios))
|
||||
for _, value := range item.Studios {
|
||||
out = append(out, value.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func peopleNames(item Item, kind string) []string {
|
||||
out := []string{}
|
||||
for _, value := range item.People {
|
||||
if strings.EqualFold(value.Type, kind) {
|
||||
out = append(out, value.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func primaryGenre(item Item) string {
|
||||
if len(item.Genres) == 0 {
|
||||
return ""
|
||||
}
|
||||
return normalizeDimension(item.Genres[0])
|
||||
}
|
||||
func leadPerson(item Item) string {
|
||||
for _, value := range item.People {
|
||||
if strings.EqualFold(value.Type, "actor") {
|
||||
return normalizeDimension(value.Name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func containsFold(values []string, wanted string) bool {
|
||||
for _, value := range values {
|
||||
if strings.EqualFold(value, wanted) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen, out := map[string]bool{}, []string{}
|
||||
for _, value := range values {
|
||||
if value != "" && !seen[value] {
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func isRomanNumeral(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if !strings.ContainsRune("ivxlcdm", r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func minPositive(a, b int) int {
|
||||
if a <= 0 || b < a {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// stableFraction is kept for deterministic future exploration bucketing.
|
||||
func stableFraction(value string) float64 {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(value))
|
||||
return float64(h.Sum32()) / float64(math.MaxUint32)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package recommend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func rankedFixture(id, name, kind string, genres []string, minutes int) Item {
|
||||
raw, _ := json.Marshal(map[string]any{
|
||||
"Id": id, "Name": name, "Type": kind, "Genres": genres,
|
||||
"RunTimeTicks": int64(minutes) * 600_000_000,
|
||||
})
|
||||
item := Item{
|
||||
ID: id, Name: name, Type: kind, Genres: genres,
|
||||
RunTimeTicks: int64(minutes) * 600_000_000, CommunityRating: 7,
|
||||
Raw: raw,
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func TestWeightedRankPersonalizesTwoUsersFromRepeatedCompletedPatterns(t *testing.T) {
|
||||
now := time.Date(2026, 7, 31, 20, 0, 0, 0, time.UTC)
|
||||
dramaHistory := []ViewingEvidence{
|
||||
{Item: rankedFixture("d1", "Drama One", "Movie", []string{"Drama"}, 100), Completion: 1, OccurredAt: now.Add(-24 * time.Hour)},
|
||||
{Item: rankedFixture("d2", "Drama Two", "Series", []string{"Drama"}, 45), Completion: .96, OccurredAt: now.Add(-48 * time.Hour)},
|
||||
}
|
||||
comedyHistory := []ViewingEvidence{
|
||||
{Item: rankedFixture("c1", "Comedy One", "Movie", []string{"Comedy"}, 95), Completion: 1, OccurredAt: now.Add(-24 * time.Hour)},
|
||||
{Item: rankedFixture("c2", "Comedy Two", "Series", []string{"Comedy"}, 25), Completion: .98, OccurredAt: now.Add(-48 * time.Hour)},
|
||||
}
|
||||
candidates := []Item{
|
||||
rankedFixture("new-comedy", "A Comedy", "Movie", []string{"Comedy"}, 90),
|
||||
rankedFixture("new-drama", "A Drama", "Movie", []string{"Drama"}, 90),
|
||||
}
|
||||
cfg := DefaultWeightedConfig()
|
||||
drama := WeightedRank(BuildWeightedProfile(dramaHistory, now, time.UTC), candidates, nil, RankIntent{Now: now}, cfg, 10)
|
||||
comedy := WeightedRank(BuildWeightedProfile(comedyHistory, now, time.UTC), candidates, nil, RankIntent{Now: now}, cfg, 10)
|
||||
if drama[0].Item.ID != "new-drama" || comedy[0].Item.ID != "new-comedy" {
|
||||
t.Fatalf("personalized first items = drama:%s comedy:%s", drama[0].Item.ID, comedy[0].Item.ID)
|
||||
}
|
||||
if drama[0].Explanation.Components["genre"] <= 0 ||
|
||||
len(drama[0].Explanation.Reasons) == 0 {
|
||||
t.Fatalf("missing explainable component scores: %#v", drama[0].Explanation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedRankRequiresRepeatedAffinityEvidence(t *testing.T) {
|
||||
now := time.Now()
|
||||
profile := BuildWeightedProfile([]ViewingEvidence{{
|
||||
Item: rankedFixture("once", "One Accidental Start", "Movie", []string{"Horror"}, 90),
|
||||
Completion: .05, OccurredAt: now,
|
||||
}}, now, time.UTC)
|
||||
ranked := WeightedRank(profile, []Item{
|
||||
rankedFixture("horror", "Horror", "Movie", []string{"Horror"}, 90),
|
||||
rankedFixture("other", "Other", "Movie", []string{"Drama"}, 90),
|
||||
}, nil, RankIntent{Now: now}, DefaultWeightedConfig(), 10)
|
||||
for _, value := range ranked {
|
||||
if value.Explanation.Components["genre"] != 0 {
|
||||
t.Fatalf("one event established genre affinity: %#v", value.Explanation.Components)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnboardingRatingCreatesImmediateMetadataAffinity(t *testing.T) {
|
||||
profile := WeightedProfile{}
|
||||
rated := rankedFixture(
|
||||
"rated", "Arrival", "Movie", []string{"Science Fiction", "Drama"}, 116,
|
||||
)
|
||||
rated.Studios = []struct {
|
||||
Name string `json:"Name"`
|
||||
}{{Name: "Paramount"}}
|
||||
rated.People = []Person{
|
||||
{Name: "Amy Adams", Type: "Actor"},
|
||||
{Name: "Denis Villeneuve", Type: "Director"},
|
||||
}
|
||||
profile.ApplyOnboardingRating(rated, 5, 2)
|
||||
|
||||
candidate := rankedFixture(
|
||||
"candidate", "Another Arrival", "Movie", []string{"Science Fiction"}, 120,
|
||||
)
|
||||
candidate.Studios = rated.Studios
|
||||
candidate.People = rated.People
|
||||
ranked := WeightedRank(
|
||||
profile, []Item{candidate}, nil, RankIntent{}, DefaultWeightedConfig(), 1,
|
||||
)
|
||||
if len(ranked) != 1 {
|
||||
t.Fatal("rated metadata produced no candidate")
|
||||
}
|
||||
components := ranked[0].Explanation.Components
|
||||
for _, key := range []string{"genre", "studio", "actor", "director"} {
|
||||
if components[key] <= 0 {
|
||||
t.Fatalf("%s component = %.3f, want positive: %#v", key, components[key], components)
|
||||
}
|
||||
}
|
||||
if !profile.Seen[rated.ID] {
|
||||
t.Fatal("the explicitly rated title was not marked seen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedRankNewReleaseEligibilityPrecedesPersonalization(t *testing.T) {
|
||||
now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
|
||||
old := rankedFixture("old", "Perfect Old Match", "Movie", []string{"Drama"}, 90)
|
||||
old.PremiereDate = "2020-01-01T00:00:00Z"
|
||||
fresh := rankedFixture("fresh", "Fresh Adjacent", "Movie", []string{"Comedy"}, 90)
|
||||
fresh.PremiereDate = "2026-07-01T00:00:00Z"
|
||||
profile := BuildWeightedProfile([]ViewingEvidence{
|
||||
{Item: rankedFixture("d1", "D1", "Movie", []string{"Drama"}, 90), Completion: 1},
|
||||
{Item: rankedFixture("d2", "D2", "Movie", []string{"Drama"}, 90), Completion: 1},
|
||||
}, now, time.UTC)
|
||||
ranked := WeightedRank(profile, []Item{old, fresh}, nil, RankIntent{
|
||||
Now: now, NewReleasesOnly: true,
|
||||
}, DefaultWeightedConfig(), 10)
|
||||
if len(ranked) != 1 || ranked[0].Item.ID != "fresh" {
|
||||
t.Fatalf("new release eligibility = %#v", ranked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedRankExcludesNegativeAndPenalizesIgnoredImpressions(t *testing.T) {
|
||||
now := time.Now()
|
||||
profile := WeightedProfile{
|
||||
Genres: map[string]Affinity{}, Studios: map[string]Affinity{},
|
||||
Actors: map[string]Affinity{}, Directors: map[string]Affinity{},
|
||||
Franchises: map[string]Affinity{}, RuntimeRanges: map[string]Affinity{},
|
||||
AgeRatings: map[string]Affinity{}, ReleasePeriods: map[string]Affinity{},
|
||||
ContentTypes: map[string]Affinity{}, Seen: map[string]bool{},
|
||||
ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{"blocked": true},
|
||||
TypicalSessionMins: map[string]float64{}, SessionEvidence: map[string]int{},
|
||||
}
|
||||
items := []Item{
|
||||
rankedFixture("ignored", "Ignored", "Movie", nil, 90),
|
||||
rankedFixture("fresh", "Fresh", "Movie", nil, 90),
|
||||
rankedFixture("blocked", "Blocked", "Movie", nil, 90),
|
||||
}
|
||||
ranked := WeightedRank(profile, items, map[string]ItemExposure{
|
||||
"ignored": {Impressions: 12},
|
||||
}, RankIntent{Now: now}, DefaultWeightedConfig(), 10)
|
||||
if len(ranked) != 2 || ranked[0].Item.ID != "fresh" {
|
||||
t.Fatalf("fatigue/negative ordering = %#v", ranked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightedRankSessionFitSupportsBeforeBedIntent(t *testing.T) {
|
||||
now := time.Date(2026, 7, 31, 23, 0, 0, 0, time.UTC)
|
||||
slot := currentContextSlot(now, time.UTC)
|
||||
profile := WeightedProfile{
|
||||
Genres: map[string]Affinity{}, Studios: map[string]Affinity{},
|
||||
Actors: map[string]Affinity{}, Directors: map[string]Affinity{},
|
||||
Franchises: map[string]Affinity{}, RuntimeRanges: map[string]Affinity{},
|
||||
AgeRatings: map[string]Affinity{}, ReleasePeriods: map[string]Affinity{},
|
||||
ContentTypes: map[string]Affinity{}, Seen: map[string]bool{},
|
||||
ExplicitPositive: map[string]bool{}, ExplicitNegative: map[string]bool{},
|
||||
TypicalSessionMins: map[string]float64{slot: 28}, SessionEvidence: map[string]int{slot: 5},
|
||||
}
|
||||
ranked := WeightedRank(profile, []Item{
|
||||
rankedFixture("film", "Long Film", "Movie", nil, 125),
|
||||
rankedFixture("episode", "One Episode", "Series", nil, 27),
|
||||
}, nil, RankIntent{Now: now, Location: time.UTC, PreferShort: true}, DefaultWeightedConfig(), 10)
|
||||
if ranked[0].Item.ID != "episode" {
|
||||
t.Fatalf("bedtime ordering = %s", ranked[0].Item.ID)
|
||||
}
|
||||
}
|
||||
@@ -26,13 +26,65 @@ type Image struct {
|
||||
}
|
||||
|
||||
type Series struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Overview string `json:"overview"`
|
||||
Year int `json:"year"`
|
||||
Network string `json:"network"`
|
||||
Genres []string `json:"genres"`
|
||||
Images []Image `json:"images"`
|
||||
ID int `json:"id"`
|
||||
TVDBID int `json:"tvdbId"`
|
||||
Title string `json:"title"`
|
||||
TitleSlug string `json:"titleSlug"`
|
||||
Overview string `json:"overview"`
|
||||
Year int `json:"year"`
|
||||
Network string `json:"network"`
|
||||
Genres []string `json:"genres"`
|
||||
Images []Image `json:"images"`
|
||||
RootFolderPath string `json:"rootFolderPath,omitempty"`
|
||||
QualityProfileID int `json:"qualityProfileId,omitempty"`
|
||||
Monitored bool `json:"monitored"`
|
||||
SeasonFolder bool `json:"seasonFolder"`
|
||||
Seasons []Season `json:"seasons"`
|
||||
Status string `json:"status"`
|
||||
NextAiring *time.Time `json:"nextAiring"`
|
||||
}
|
||||
|
||||
// Series returns Sonarr's current catalogue, including lifecycle and next-airing data.
|
||||
func (c *Client) Series(ctx context.Context) ([]Series, error) {
|
||||
var series []Series
|
||||
if err := c.get(ctx, "/api/v3/series", &series); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// Episodes returns Sonarr's complete episode list for one series. Unlike the calendar,
|
||||
// this includes future episodes, which is what makes finale detection trustworthy rather
|
||||
// than mistaking the newest downloaded episode for the end of a season.
|
||||
func (c *Client) Episodes(ctx context.Context, seriesID int) ([]Episode, error) {
|
||||
if seriesID <= 0 {
|
||||
return nil, fmt.Errorf("sonarr: invalid series id")
|
||||
}
|
||||
req, err := c.request(ctx, "/api/v3/episode", url.Values{
|
||||
"seriesId": {strconv.Itoa(seriesID)},
|
||||
"includeSeries": {"true"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var episodes []Episode
|
||||
if err := c.do(req, &episodes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
type Season struct {
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Monitored bool `json:"monitored"`
|
||||
}
|
||||
|
||||
type RootFolder struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type QualityProfile struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type EpisodeFile struct {
|
||||
@@ -101,6 +153,50 @@ func (c *Client) Calendar(ctx context.Context, start, end time.Time) ([]Episode,
|
||||
return episodes, nil
|
||||
}
|
||||
|
||||
func (c *Client) Lookup(ctx context.Context, term string) ([]Series, error) {
|
||||
req, err := c.request(ctx, "/api/v3/series/lookup", url.Values{"term": {term}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var series []Series
|
||||
if err := c.do(req, &series); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// AddUnmonitored adds a series without monitoring it or starting an episode search.
|
||||
func (c *Client) AddUnmonitored(ctx context.Context, series Series) (Series, error) {
|
||||
var roots []RootFolder
|
||||
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
|
||||
return Series{}, err
|
||||
}
|
||||
var profiles []QualityProfile
|
||||
if err := c.get(ctx, "/api/v3/qualityprofile", &profiles); err != nil {
|
||||
return Series{}, err
|
||||
}
|
||||
if len(roots) == 0 || len(profiles) == 0 {
|
||||
return Series{}, fmt.Errorf("sonarr: no root folder or quality profile configured")
|
||||
}
|
||||
series.ID = 0
|
||||
series.RootFolderPath = roots[0].Path
|
||||
series.QualityProfileID = profiles[0].ID
|
||||
series.Monitored = false
|
||||
series.SeasonFolder = true
|
||||
for i := range series.Seasons {
|
||||
series.Seasons[i].Monitored = false
|
||||
}
|
||||
body := struct {
|
||||
Series
|
||||
AddOptions map[string]bool `json:"addOptions"`
|
||||
}{Series: series, AddOptions: map[string]bool{"searchForMissingEpisodes": false}}
|
||||
var added Series
|
||||
if err := c.post(ctx, "/api/v3/series", body, &added); err != nil {
|
||||
return Series{}, err
|
||||
}
|
||||
return added, nil
|
||||
}
|
||||
|
||||
// MediaCover fetches a series poster or fanart without exposing the Sonarr API key.
|
||||
func (c *Client) MediaCover(ctx context.Context, seriesID int, coverType string) (*http.Response, error) {
|
||||
if seriesID <= 0 || (coverType != "poster" && coverType != "fanart") {
|
||||
@@ -138,6 +234,29 @@ func (c *Client) request(ctx context.Context, path string, params url.Values) (*
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, out any) error {
|
||||
req, err := c.request(ctx, path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, out)
|
||||
}
|
||||
|
||||
func (c *Client) post(ctx context.Context, path string, body, out any) error {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sonarr: encode request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Api-Key", c.apiKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return c.do(req, out)
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request, out any) error {
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package sonarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -45,6 +46,51 @@ func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/rootfolder":
|
||||
_, _ = w.Write([]byte(`[{"path":"/tv"}]`))
|
||||
case "/api/v3/qualityprofile":
|
||||
_, _ = w.Write([]byte(`[{"id":3}]`))
|
||||
case "/api/v3/series":
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["monitored"] != false || body["seasonFolder"] != true ||
|
||||
body["rootFolderPath"] != "/tv" || body["qualityProfileId"] != float64(3) {
|
||||
t.Errorf("unexpected add body: %#v", body)
|
||||
}
|
||||
seasons := body["seasons"].([]any)
|
||||
if seasons[0].(map[string]any)["monitored"] != false {
|
||||
t.Errorf("season remained monitored: %#v", body)
|
||||
}
|
||||
options := body["addOptions"].(map[string]any)
|
||||
if options["searchForMissingEpisodes"] != false {
|
||||
t.Errorf("episode search was enabled: %#v", body)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":8,"tvdbId":44,"title":"Severance"}`))
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
added, err := New(upstream.URL, "secret", time.Second).AddUnmonitored(
|
||||
context.Background(), Series{
|
||||
TVDBID: 44, Title: "Severance", Monitored: true,
|
||||
Seasons: []Season{{SeasonNumber: 1, Monitored: true}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if added.ID != 8 {
|
||||
t.Fatalf("unexpected series: %+v", added)
|
||||
}
|
||||
}
|
||||
|
||||
func rHasAPIKey(query string) bool {
|
||||
return strings.Contains(query, "secret")
|
||||
}
|
||||
|
||||
@@ -78,6 +78,46 @@ type RowStat struct {
|
||||
SelectRate float64 `json:"selectRate"`
|
||||
}
|
||||
|
||||
// UserRowStats is the per-profile counterpart to the admin aggregate. It gives the
|
||||
// home composer enough evidence to gently demote shelves that a viewer repeatedly
|
||||
// passes over without turning a couple of accidental focus moves into a preference.
|
||||
func (s *Store) UserRowStats(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
since time.Time,
|
||||
) ([]RowStat, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT row_id,
|
||||
(array_agg(row_kind ORDER BY occurred_at DESC))[1] AS row_kind,
|
||||
count(*) FILTER (WHERE event = 'impression') AS impressions,
|
||||
count(*) FILTER (WHERE event = 'focus') AS focuses,
|
||||
count(*) FILTER (WHERE event = 'select') AS selects,
|
||||
coalesce(sum(dwell_ms), 0) AS dwell_ms
|
||||
FROM row_events
|
||||
WHERE emby_user_id = $1 AND occurred_at >= $2
|
||||
GROUP BY row_id`, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: user row stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
stats := []RowStat{}
|
||||
for rows.Next() {
|
||||
var stat RowStat
|
||||
if err := rows.Scan(
|
||||
&stat.RowID, &stat.RowKind, &stat.Impressions, &stat.Focuses,
|
||||
&stat.Selects, &stat.DwellMs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stat.Viewers = 1
|
||||
if stat.Impressions > 0 {
|
||||
stat.SelectRate = float64(stat.Selects) / float64(stat.Impressions)
|
||||
}
|
||||
stats = append(stats, stat)
|
||||
}
|
||||
return stats, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) InsertRowEvents(ctx context.Context, events []RowEvent) error {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -17,6 +18,16 @@ type TracearrSessionKey struct {
|
||||
SessionID string
|
||||
}
|
||||
|
||||
type TracearrSessionSignal struct {
|
||||
Fingerprint []byte
|
||||
Terminal bool
|
||||
}
|
||||
|
||||
type RecommendationIdentity struct {
|
||||
TracearrUserID string
|
||||
Username string
|
||||
}
|
||||
|
||||
type TracearrSession struct {
|
||||
ServerID string
|
||||
SessionID string
|
||||
@@ -65,7 +76,10 @@ type RecommendationProfile struct {
|
||||
GenreAffinity json.RawMessage
|
||||
TitleAffinity json.RawMessage
|
||||
StudioAffinity json.RawMessage
|
||||
ContextAffinity json.RawMessage
|
||||
CodecOutcomes json.RawMessage
|
||||
WeightedProfile json.RawMessage
|
||||
AlgorithmVersion string
|
||||
SignalsThrough *time.Time
|
||||
BuiltAt time.Time
|
||||
}
|
||||
@@ -89,6 +103,8 @@ type ForYouCandidate struct {
|
||||
type PreparedForYouItem struct {
|
||||
ItemID string
|
||||
BaseRank int
|
||||
BaseScore float64
|
||||
AffinityScore float64
|
||||
Payload json.RawMessage
|
||||
RuntimeMinutes int
|
||||
CompatibilityScore float64
|
||||
@@ -98,6 +114,7 @@ type PreparedForYouItem struct {
|
||||
ReasonGenre string
|
||||
ReasonSourceItemID string
|
||||
ReasonSourceTitle string
|
||||
ContextAffinity json.RawMessage
|
||||
}
|
||||
|
||||
type ForYouStats struct {
|
||||
@@ -151,6 +168,46 @@ func (s *Store) TracearrFingerprints(
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) TracearrSessionSignals(
|
||||
ctx context.Context,
|
||||
keys []TracearrSessionKey,
|
||||
) (map[TracearrSessionKey]TracearrSessionSignal, error) {
|
||||
out := make(map[TracearrSessionKey]TracearrSessionSignal, len(keys))
|
||||
if len(keys) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
servers := make([]string, 0, len(keys))
|
||||
ids := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
servers = append(servers, key.ServerID)
|
||||
ids = append(ids, key.SessionID)
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT current.server_id, current.tracearr_session_id, current.source_fingerprint,
|
||||
(current.watched OR current.stopped_at IS NOT NULL OR
|
||||
lower(current.state) IN ('stopped', 'completed', 'complete', 'ended'))
|
||||
FROM tracearr_sessions current
|
||||
JOIN unnest($1::text[], $2::text[]) wanted(server_id, session_id)
|
||||
ON current.server_id = wanted.server_id
|
||||
AND current.tracearr_session_id = wanted.session_id`,
|
||||
servers, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: tracearr session signals: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var key TracearrSessionKey
|
||||
var signal TracearrSessionSignal
|
||||
if err := rows.Scan(
|
||||
&key.ServerID, &key.SessionID, &signal.Fingerprint, &signal.Terminal,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[key] = signal
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertTracearrSessions(
|
||||
ctx context.Context,
|
||||
sessions []TracearrSession,
|
||||
@@ -354,7 +411,7 @@ func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT ON (emby_user_id)
|
||||
token_hash, emby_user_id, emby_token, username, server_id, device_id,
|
||||
device_name, client_version, client_protocol, last_seen_at
|
||||
device_name, client_version, client_protocol, client_capabilities, last_seen_at
|
||||
FROM sessions
|
||||
ORDER BY emby_user_id, last_seen_at DESC`)
|
||||
if err != nil {
|
||||
@@ -367,7 +424,7 @@ func (s *Store) ActiveRecommendationUsers(ctx context.Context) ([]Session, error
|
||||
if err := rows.Scan(
|
||||
&session.TokenHash, &session.EmbyUserID, &session.EmbyToken, &session.Username,
|
||||
&session.ServerID, &session.DeviceID, &session.DeviceName, &session.ClientVersion,
|
||||
&session.ClientProtocol, &session.LastSeenAt,
|
||||
&session.ClientProtocol, &session.ClientCapabilities, &session.LastSeenAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -400,6 +457,35 @@ func (s *Store) MarkAllForYouProfilesDirty(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkForYouProfilesDirtyByTracearrIdentity(
|
||||
ctx context.Context,
|
||||
identities []RecommendationIdentity,
|
||||
) (int64, error) {
|
||||
if len(identities) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
userIDs := make([]string, 0, len(identities))
|
||||
usernames := make([]string, 0, len(identities))
|
||||
for _, identity := range identities {
|
||||
if identity.TracearrUserID != "" {
|
||||
userIDs = append(userIDs, identity.TracearrUserID)
|
||||
}
|
||||
if identity.Username != "" {
|
||||
usernames = append(usernames, strings.ToLower(identity.Username))
|
||||
}
|
||||
}
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE recommendation_user_profiles
|
||||
SET dirty_since = coalesce(dirty_since, now())
|
||||
WHERE tracearr_user_id = ANY($1)
|
||||
OR lower(tracearr_username) = ANY($2)`,
|
||||
userIDs, usernames)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("store: mark affected For You profiles dirty: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (s *Store) MatchRecommendationUser(
|
||||
ctx context.Context,
|
||||
embyUserID, embyUsername, tracearrUserID, tracearrUsername string,
|
||||
@@ -431,18 +517,18 @@ func (s *Store) MatchRecommendationUser(
|
||||
func (s *Store) ForYouProfileTimes(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
) (builtAt, poolBuiltAt, dirtySince *time.Time, err error) {
|
||||
) (builtAt, poolBuiltAt, dirtySince *time.Time, algorithmVersion string, err error) {
|
||||
err = s.pool.QueryRow(ctx, `
|
||||
SELECT built_at, pool_built_at, dirty_since
|
||||
SELECT built_at, pool_built_at, dirty_since, algorithm_version
|
||||
FROM recommendation_user_profiles WHERE emby_user_id = $1`, userID).
|
||||
Scan(&builtAt, &poolBuiltAt, &dirtySince)
|
||||
Scan(&builtAt, &poolBuiltAt, &dirtySince, &algorithmVersion)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil, nil, nil
|
||||
return nil, nil, nil, "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("store: For You freshness: %w", err)
|
||||
return nil, nil, nil, "", fmt.Errorf("store: For You freshness: %w", err)
|
||||
}
|
||||
return builtAt, poolBuiltAt, dirtySince, nil
|
||||
return builtAt, poolBuiltAt, dirtySince, algorithmVersion, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetForYouError(ctx context.Context, userID string, buildErr error) error {
|
||||
@@ -474,10 +560,11 @@ func (s *Store) ReplaceForYouPool(
|
||||
INSERT INTO recommendation_user_profiles (
|
||||
emby_user_id, tracearr_user_id, tracearr_username, source_session_count,
|
||||
mean_completion_ratio, typical_session_minutes, genre_affinity,
|
||||
title_affinity, studio_affinity, codec_outcomes, signals_through,
|
||||
built_at, pool_built_at, dirty_since, last_error
|
||||
title_affinity, studio_affinity, context_affinity, codec_outcomes,
|
||||
weighted_profile, algorithm_version, signals_through, built_at, pool_built_at, dirty_since, last_error
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11,$12,$12,NULL,''
|
||||
$1,$2,$3,$4,$5,$6,$7::jsonb,$8::jsonb,$9::jsonb,$10::jsonb,$11::jsonb,
|
||||
$12::jsonb,$13,$14,$15,$15,NULL,''
|
||||
)
|
||||
ON CONFLICT (emby_user_id) DO UPDATE SET
|
||||
tracearr_user_id = EXCLUDED.tracearr_user_id,
|
||||
@@ -488,7 +575,10 @@ func (s *Store) ReplaceForYouPool(
|
||||
genre_affinity = EXCLUDED.genre_affinity,
|
||||
title_affinity = EXCLUDED.title_affinity,
|
||||
studio_affinity = EXCLUDED.studio_affinity,
|
||||
context_affinity = EXCLUDED.context_affinity,
|
||||
codec_outcomes = EXCLUDED.codec_outcomes,
|
||||
weighted_profile = EXCLUDED.weighted_profile,
|
||||
algorithm_version = EXCLUDED.algorithm_version,
|
||||
signals_through = EXCLUDED.signals_through,
|
||||
built_at = EXCLUDED.built_at,
|
||||
pool_built_at = EXCLUDED.pool_built_at,
|
||||
@@ -498,7 +588,9 @@ func (s *Store) ReplaceForYouPool(
|
||||
profile.SourceSessionCount, profile.MeanCompletionRatio,
|
||||
profile.TypicalSessionMinutes, string(profile.GenreAffinity),
|
||||
string(profile.TitleAffinity), string(profile.StudioAffinity),
|
||||
string(profile.CodecOutcomes), profile.SignalsThrough, profile.BuiltAt)
|
||||
string(profile.ContextAffinity), string(profile.CodecOutcomes),
|
||||
string(profile.WeightedProfile), profile.AlgorithmVersion,
|
||||
profile.SignalsThrough, profile.BuiltAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write For You profile: %w", err)
|
||||
}
|
||||
@@ -544,15 +636,20 @@ func (s *Store) PreparedForYou(
|
||||
minutes, limit int,
|
||||
) ([]PreparedForYouItem, *time.Time, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT fc.item_id, fc.base_rank, li.payload, fc.runtime_minutes,
|
||||
SELECT fc.item_id, fc.base_rank, fc.base_score, fc.affinity_score,
|
||||
li.payload, fc.runtime_minutes,
|
||||
fc.compatibility_score, fc.compatibility_label,
|
||||
fc.recommendation_reason, fc.reason_kind, fc.reason_genre,
|
||||
fc.reason_source_item_id, fc.reason_source_title, p.pool_built_at
|
||||
fc.reason_source_item_id, fc.reason_source_title, p.context_affinity,
|
||||
p.pool_built_at
|
||||
FROM for_you_candidates fc
|
||||
JOIN library_items li ON li.id = fc.item_id
|
||||
JOIN recommendation_user_profiles p ON p.emby_user_id = fc.emby_user_id
|
||||
WHERE fc.emby_user_id = $1
|
||||
AND ($2 = 0 OR (fc.runtime_minutes > 0 AND fc.runtime_minutes <= $2))
|
||||
AND (
|
||||
$2 = 0 OR fc.reason_kind = 'pick-up' OR
|
||||
(fc.runtime_minutes > 0 AND fc.runtime_minutes <= $2)
|
||||
)
|
||||
ORDER BY fc.base_rank
|
||||
LIMIT $3`,
|
||||
userID, minutes, limit)
|
||||
@@ -567,10 +664,12 @@ func (s *Store) PreparedForYou(
|
||||
var item PreparedForYouItem
|
||||
var rowBuiltAt *time.Time
|
||||
if err := rows.Scan(
|
||||
&item.ItemID, &item.BaseRank, &payload, &item.RuntimeMinutes,
|
||||
&item.ItemID, &item.BaseRank, &item.BaseScore, &item.AffinityScore,
|
||||
&payload, &item.RuntimeMinutes,
|
||||
&item.CompatibilityScore, &item.CompatibilityLabel,
|
||||
&item.RecommendationReason, &item.ReasonKind, &item.ReasonGenre,
|
||||
&item.ReasonSourceItemID, &item.ReasonSourceTitle, &rowBuiltAt,
|
||||
&item.ReasonSourceItemID, &item.ReasonSourceTitle, &item.ContextAffinity,
|
||||
&rowBuiltAt,
|
||||
); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ type LibraryStats struct {
|
||||
LastSynced *time.Time `json:"lastSynced"`
|
||||
}
|
||||
|
||||
// UpsertLibraryItems writes a batch, refreshing synced_at on every row it touches.
|
||||
// UpsertLibraryItems writes a batch, refreshing synced_at on every row it touches, and
|
||||
// returns how many recommendation-relevant payloads were inserted or actually changed.
|
||||
//
|
||||
// synced_at doubles as the mark-and-sweep marker: a full import stamps everything it
|
||||
// sees, then deletes whatever kept an older stamp.
|
||||
@@ -46,23 +47,48 @@ func (s *Store) UpsertLibraryItems(ctx context.Context, items []LibraryItem, syn
|
||||
batch := &pgx.Batch{}
|
||||
for _, item := range items {
|
||||
batch.Queue(`
|
||||
INSERT INTO library_items (
|
||||
id, type, name, series_id, series_name, production_year, community_rating,
|
||||
genres, studios, date_created, search_text, payload, synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
name = EXCLUDED.name,
|
||||
series_id = EXCLUDED.series_id,
|
||||
series_name = EXCLUDED.series_name,
|
||||
production_year = EXCLUDED.production_year,
|
||||
community_rating = EXCLUDED.community_rating,
|
||||
genres = EXCLUDED.genres,
|
||||
studios = EXCLUDED.studios,
|
||||
date_created = EXCLUDED.date_created,
|
||||
search_text = EXCLUDED.search_text,
|
||||
payload = EXCLUDED.payload,
|
||||
synced_at = EXCLUDED.synced_at`,
|
||||
WITH previous AS MATERIALIZED (
|
||||
SELECT type, name, series_id, series_name, production_year,
|
||||
community_rating, genres, studios, date_created,
|
||||
payload->'RunTimeTicks' AS runtime_ticks,
|
||||
payload->'MediaStreams' AS media_streams,
|
||||
payload->'Container' AS container
|
||||
FROM library_items WHERE id = $1
|
||||
), upserted AS (
|
||||
INSERT INTO library_items (
|
||||
id, type, name, series_id, series_name, production_year, community_rating,
|
||||
genres, studios, date_created, search_text, payload, synced_at
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
name = EXCLUDED.name,
|
||||
series_id = EXCLUDED.series_id,
|
||||
series_name = EXCLUDED.series_name,
|
||||
production_year = EXCLUDED.production_year,
|
||||
community_rating = EXCLUDED.community_rating,
|
||||
genres = EXCLUDED.genres,
|
||||
studios = EXCLUDED.studios,
|
||||
date_created = EXCLUDED.date_created,
|
||||
search_text = EXCLUDED.search_text,
|
||||
payload = EXCLUDED.payload,
|
||||
synced_at = EXCLUDED.synced_at
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT NOT EXISTS (SELECT 1 FROM previous)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM previous
|
||||
WHERE ROW(
|
||||
type, name, series_id, series_name, production_year,
|
||||
community_rating, genres, studios, date_created,
|
||||
runtime_ticks, media_streams, container
|
||||
) IS DISTINCT FROM ROW(
|
||||
$2::text, $3::text, $4::text, $5::text, $6::int,
|
||||
$7::real, $8::text[], $9::text[], $10::timestamptz,
|
||||
$12::jsonb->'RunTimeTicks', $12::jsonb->'MediaStreams',
|
||||
$12::jsonb->'Container'
|
||||
)
|
||||
)
|
||||
FROM upserted`,
|
||||
item.ID, item.Type, item.Name, item.SeriesID, item.SeriesName,
|
||||
item.ProductionYear, item.CommunityRating, item.Genres, item.Studios,
|
||||
item.DateCreated, item.SearchText, string(item.Payload), syncedAt)
|
||||
@@ -71,15 +97,17 @@ func (s *Store) UpsertLibraryItems(ctx context.Context, items []LibraryItem, syn
|
||||
results := s.pool.SendBatch(ctx, batch)
|
||||
defer results.Close()
|
||||
|
||||
var written int64
|
||||
var changed int64
|
||||
for range items {
|
||||
tag, err := results.Exec()
|
||||
if err != nil {
|
||||
return written, fmt.Errorf("store: upsert library items: %w", err)
|
||||
var recommendationChanged bool
|
||||
if err := results.QueryRow().Scan(&recommendationChanged); err != nil {
|
||||
return changed, fmt.Errorf("store: upsert library items: %w", err)
|
||||
}
|
||||
if recommendationChanged {
|
||||
changed++
|
||||
}
|
||||
written += tag.RowsAffected()
|
||||
}
|
||||
return written, nil
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
// DeleteLibraryItemsBefore removes anything a full import did not touch — items deleted
|
||||
@@ -153,6 +181,23 @@ func (s *Store) AllRecommendationCandidates(ctx context.Context) ([]json.RawMess
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
func (s *Store) LibraryItemsByID(
|
||||
ctx context.Context,
|
||||
ids []string,
|
||||
) ([]json.RawMessage, error) {
|
||||
if len(ids) == 0 {
|
||||
return []json.RawMessage{}, nil
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT payload
|
||||
FROM library_items
|
||||
WHERE id = ANY($1)`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: library items by id: %w", err)
|
||||
}
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
// CuratedCandidates filters the imported catalogue for a server-authored shelf. Arrays
|
||||
// are matched case-insensitively because Emby studio capitalisation is not consistent.
|
||||
func (s *Store) CuratedCandidates(
|
||||
@@ -190,6 +235,46 @@ func (s *Store) CuratedCandidates(
|
||||
return collectPayloads(rows)
|
||||
}
|
||||
|
||||
// LibraryGenres returns every genre with enough catalogue depth to make a shelf feel
|
||||
// intentional. The recommendation engine still applies per-user seen filtering and may
|
||||
// drop a shelf afterwards when too few unseen titles remain.
|
||||
func (s *Store) LibraryGenres(
|
||||
ctx context.Context,
|
||||
itemTypes []string,
|
||||
minItems int,
|
||||
) ([]string, error) {
|
||||
if len(itemTypes) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if minItems < 1 {
|
||||
minItems = 1
|
||||
}
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT genre, count(DISTINCT item.id) AS item_count
|
||||
FROM library_items AS item
|
||||
CROSS JOIN LATERAL unnest(item.genres) AS genre
|
||||
WHERE item.type = ANY($1)
|
||||
AND btrim(genre) <> ''
|
||||
GROUP BY genre
|
||||
HAVING count(DISTINCT item.id) >= $2
|
||||
ORDER BY item_count DESC, lower(genre) ASC`,
|
||||
itemTypes, minItems)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: library genres: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []string{}
|
||||
for rows.Next() {
|
||||
var genre string
|
||||
var count int
|
||||
if err := rows.Scan(&genre, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, genre)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func lowerStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UserShow struct {
|
||||
ItemID string `json:"itemId"`
|
||||
Title string `json:"title"`
|
||||
Year *int `json:"year,omitempty"`
|
||||
ImageTag string `json:"imageTag,omitempty"`
|
||||
AddedAt time.Time `json:"addedAt"`
|
||||
}
|
||||
|
||||
type NotificationPreferences struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ShowReturnAlerts bool `json:"showReturnAlerts"`
|
||||
LeadDays int `json:"leadDays"`
|
||||
}
|
||||
|
||||
type UserNotification struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
ItemID string `json:"itemId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
EventAt *time.Time `json:"eventAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ReadAt *time.Time `json:"readAt,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Store) SaveUserShow(ctx context.Context, userID string, show UserShow) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (emby_user_id, item_id) DO UPDATE
|
||||
SET title = EXCLUDED.title, year = EXCLUDED.year, image_tag = EXCLUDED.image_tag`,
|
||||
userID, show.ItemID, show.Title, show.Year, show.ImageTag)
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveUserShowIfAbsent is the auto-follow path. Manual saves intentionally refresh
|
||||
// metadata, while playback must only announce a show when it actually added it.
|
||||
func (s *Store) SaveUserShowIfAbsent(ctx context.Context, userID string, show UserShow) (bool, error) {
|
||||
result, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_shows (emby_user_id, item_id, title, year, image_tag)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (emby_user_id, item_id) DO NOTHING`,
|
||||
userID, show.ItemID, show.Title, show.Year, show.ImageTag)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return result.RowsAffected() == 1, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteUserShow(ctx context.Context, userID, itemID string) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
WITH removed AS (
|
||||
DELETE FROM user_shows
|
||||
WHERE emby_user_id = $1 AND item_id = $2
|
||||
RETURNING item_id
|
||||
)
|
||||
UPDATE user_notifications SET dismissed_at = now()
|
||||
WHERE emby_user_id = $1
|
||||
AND item_id IN (SELECT item_id FROM removed)
|
||||
AND dismissed_at IS NULL`,
|
||||
userID, itemID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UserShows(ctx context.Context, userID string) ([]UserShow, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT item_id, title, year, image_tag, added_at
|
||||
FROM user_shows WHERE emby_user_id = $1 ORDER BY added_at, lower(title)`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list user shows: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
shows := []UserShow{}
|
||||
for rows.Next() {
|
||||
var show UserShow
|
||||
if err := rows.Scan(&show.ItemID, &show.Title, &show.Year, &show.ImageTag, &show.AddedAt); err != nil {
|
||||
return nil, fmt.Errorf("store: scan user show: %w", err)
|
||||
}
|
||||
shows = append(shows, show)
|
||||
}
|
||||
return shows, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) NotificationPreferences(ctx context.Context, userID string) (NotificationPreferences, error) {
|
||||
prefs := NotificationPreferences{Enabled: true, ShowReturnAlerts: true, LeadDays: 7}
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT enabled, show_return_alerts, lead_days
|
||||
FROM user_notification_preferences WHERE emby_user_id = $1`, userID).
|
||||
Scan(&prefs.Enabled, &prefs.ShowReturnAlerts, &prefs.LeadDays)
|
||||
if err != nil && !isNoRows(err) {
|
||||
return prefs, fmt.Errorf("store: notification preferences: %w", err)
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetNotificationPreferences(
|
||||
ctx context.Context, userID string, prefs NotificationPreferences,
|
||||
) error {
|
||||
if prefs.LeadDays < 1 {
|
||||
prefs.LeadDays = 1
|
||||
}
|
||||
if prefs.LeadDays > 30 {
|
||||
prefs.LeadDays = 30
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_notification_preferences
|
||||
(emby_user_id, enabled, show_return_alerts, lead_days)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (emby_user_id) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
show_return_alerts = EXCLUDED.show_return_alerts,
|
||||
lead_days = EXCLUDED.lead_days,
|
||||
updated_at = now()`,
|
||||
userID, prefs.Enabled, prefs.ShowReturnAlerts, prefs.LeadDays)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertNotification(
|
||||
ctx context.Context, userID, sourceKey, kind, itemID, title, message string, eventAt *time.Time,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO user_notifications
|
||||
(emby_user_id, source_key, kind, item_id, title, message, event_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (emby_user_id, source_key) DO NOTHING`,
|
||||
userID, sourceKey, kind, itemID, title, message, eventAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UserNotifications(ctx context.Context, userID string) ([]UserNotification, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT id, kind, item_id, title, message, event_at, created_at, read_at
|
||||
FROM user_notifications
|
||||
WHERE emby_user_id = $1 AND dismissed_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 100`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list notifications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
notifications := []UserNotification{}
|
||||
for rows.Next() {
|
||||
var notification UserNotification
|
||||
if err := rows.Scan(
|
||||
¬ification.ID, ¬ification.Kind, ¬ification.ItemID,
|
||||
¬ification.Title, ¬ification.Message, ¬ification.EventAt,
|
||||
¬ification.CreatedAt, ¬ification.ReadAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan notification: %w", err)
|
||||
}
|
||||
notifications = append(notifications, notification)
|
||||
}
|
||||
return notifications, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) MarkNotificationRead(ctx context.Context, userID string, id int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE user_notifications SET read_at = COALESCE(read_at, now())
|
||||
WHERE id = $1 AND emby_user_id = $2`, id, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DismissNotification(ctx context.Context, userID string, id int64) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE user_notifications SET dismissed_at = now()
|
||||
WHERE id = $1 AND emby_user_id = $2`, id, userID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type RecommendationAction struct {
|
||||
ItemID string `json:"itemId"`
|
||||
Action string `json:"action"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ItemExposureStat struct {
|
||||
ItemID string
|
||||
Impressions int
|
||||
Focuses int
|
||||
Selects int
|
||||
LastShown time.Time
|
||||
}
|
||||
|
||||
func (s *Store) HouseholdCompletionScores(
|
||||
ctx context.Context,
|
||||
since time.Time,
|
||||
) (map[string]float64, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT coalesce(nullif(emby_series_id, ''), emby_item_id) AS item_id,
|
||||
count(DISTINCT lower(username))::float8
|
||||
FROM tracearr_sessions
|
||||
WHERE started_at >= $1
|
||||
AND (watched OR (
|
||||
total_duration_ms > 0 AND progress_ms::float8 / total_duration_ms >= 0.9
|
||||
))
|
||||
AND coalesce(nullif(emby_series_id, ''), emby_item_id) <> ''
|
||||
GROUP BY item_id`, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: household completion scores: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]float64{}
|
||||
maxScore := 0.0
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var score float64
|
||||
if err := rows.Scan(&id, &score); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[id] = score
|
||||
if score > maxScore {
|
||||
maxScore = score
|
||||
}
|
||||
}
|
||||
if maxScore > 0 {
|
||||
for id, score := range out {
|
||||
out[id] = score / maxScore
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) WeightedRecommendationProfile(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
) (json.RawMessage, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT weighted_profile
|
||||
FROM recommendation_user_profiles
|
||||
WHERE emby_user_id = $1`, userID).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: weighted recommendation profile: %w", err)
|
||||
}
|
||||
return json.RawMessage(raw), nil
|
||||
}
|
||||
|
||||
func (s *Store) SetRecommendationAction(
|
||||
ctx context.Context,
|
||||
userID, itemID, action string,
|
||||
) error {
|
||||
if action != "more_like_this" && action != "not_for_me" {
|
||||
return fmt.Errorf("store: invalid recommendation action %q", action)
|
||||
}
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO recommendation_actions (emby_user_id, item_id, action, updated_at)
|
||||
VALUES ($1,$2,$3,now())
|
||||
ON CONFLICT (emby_user_id, item_id) DO UPDATE
|
||||
SET action = EXCLUDED.action, updated_at = now()`,
|
||||
userID, itemID, action)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: set recommendation action: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ClearRecommendationAction(
|
||||
ctx context.Context,
|
||||
userID, itemID string,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
DELETE FROM recommendation_actions
|
||||
WHERE emby_user_id = $1 AND item_id = $2`, userID, itemID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RecommendationActions(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
) ([]RecommendationAction, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT item_id, action, updated_at
|
||||
FROM recommendation_actions
|
||||
WHERE emby_user_id = $1`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: recommendation actions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []RecommendationAction{}
|
||||
for rows.Next() {
|
||||
var value RecommendationAction
|
||||
if err := rows.Scan(&value.ItemID, &value.Action, &value.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) RecommendationOnboarding(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
) (json.RawMessage, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT preferences FROM recommendation_onboarding WHERE emby_user_id = $1`,
|
||||
userID).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return json.RawMessage(`{}`), nil
|
||||
}
|
||||
return json.RawMessage(raw), err
|
||||
}
|
||||
|
||||
func (s *Store) SetRecommendationOnboarding(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
preferences json.RawMessage,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
INSERT INTO recommendation_onboarding (emby_user_id, preferences, updated_at)
|
||||
VALUES ($1,$2::jsonb,now())
|
||||
ON CONFLICT (emby_user_id) DO UPDATE
|
||||
SET preferences = EXCLUDED.preferences, updated_at = now()`,
|
||||
userID, string(preferences))
|
||||
return err
|
||||
}
|
||||
|
||||
// UserItemExposures is deliberately item-scoped. A row impression with no item id is
|
||||
// useful for row ordering but cannot be used to claim a particular poster was ignored.
|
||||
func (s *Store) UserItemExposures(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
since time.Time,
|
||||
) ([]ItemExposureStat, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT item_id,
|
||||
count(*) FILTER (WHERE event = 'impression'),
|
||||
count(*) FILTER (WHERE event = 'focus'),
|
||||
count(*) FILTER (WHERE event = 'select'),
|
||||
max(occurred_at)
|
||||
FROM row_events
|
||||
WHERE emby_user_id = $1 AND occurred_at >= $2 AND item_id <> ''
|
||||
GROUP BY item_id`, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: user item exposures: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []ItemExposureStat{}
|
||||
for rows.Next() {
|
||||
var value ItemExposureStat
|
||||
if err := rows.Scan(
|
||||
&value.ItemID, &value.Impressions, &value.Focuses,
|
||||
&value.Selects, &value.LastShown,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
device_name TEXT NOT NULL DEFAULT 'Memby TV',
|
||||
client_version TEXT NOT NULL DEFAULT '',
|
||||
client_protocol TEXT NOT NULL DEFAULT '',
|
||||
client_capabilities TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -20,6 +21,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS device_name TEXT NOT NULL DEFAULT 'Memby TV';
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_version TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_protocol TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS client_capabilities TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Older builds could create more than one token for the same physical TV. Keep the most
|
||||
-- recently used row before adding the identity constraint.
|
||||
@@ -119,6 +121,50 @@ CREATE TABLE IF NOT EXISTS search_history (
|
||||
CREATE INDEX IF NOT EXISTS search_history_user_time_idx
|
||||
ON search_history (emby_user_id, occurred_at DESC);
|
||||
|
||||
-- A user's explicitly followed TV series. Unlike library_items this is intentionally
|
||||
-- user-scoped: following a show is a Memby preference, not Emby library state.
|
||||
CREATE TABLE IF NOT EXISTS user_shows (
|
||||
emby_user_id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
year INT,
|
||||
image_tag TEXT NOT NULL DEFAULT '',
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (emby_user_id, item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_shows_user_added_idx
|
||||
ON user_shows (emby_user_id, added_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
emby_user_id TEXT PRIMARY KEY,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
show_return_alerts BOOLEAN NOT NULL DEFAULT true,
|
||||
lead_days INT NOT NULL DEFAULT 7 CHECK (lead_days BETWEEN 1 AND 30),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Notifications are materialised so read/dismissed state follows the user to every TV.
|
||||
-- source_key is deterministic, preventing the same return date from being announced
|
||||
-- again whenever the app refreshes.
|
||||
CREATE TABLE IF NOT EXISTS user_notifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
emby_user_id TEXT NOT NULL,
|
||||
source_key TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
event_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
read_at TIMESTAMPTZ,
|
||||
dismissed_at TIMESTAMPTZ,
|
||||
UNIQUE (emby_user_id, source_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_notifications_user_created_idx
|
||||
ON user_notifications (emby_user_id, created_at DESC);
|
||||
|
||||
-- Recommendation-relevant Tracearr history. The public Tracearr API has no user or
|
||||
-- since cursor, so stable source ids make these rows the durable deduplication boundary.
|
||||
-- Deliberately omit artwork, stream-detail blobs and other fields unused by ranking.
|
||||
@@ -180,7 +226,10 @@ CREATE TABLE IF NOT EXISTS recommendation_user_profiles (
|
||||
genre_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
title_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
studio_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
context_affinity JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
codec_outcomes JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
weighted_profile JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
algorithm_version TEXT NOT NULL DEFAULT '',
|
||||
signals_through TIMESTAMPTZ,
|
||||
built_at TIMESTAMPTZ,
|
||||
pool_built_at TIMESTAMPTZ,
|
||||
@@ -188,6 +237,32 @@ CREATE TABLE IF NOT EXISTS recommendation_user_profiles (
|
||||
last_error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
ALTER TABLE recommendation_user_profiles
|
||||
ADD COLUMN IF NOT EXISTS context_affinity JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
ALTER TABLE recommendation_user_profiles
|
||||
ADD COLUMN IF NOT EXISTS algorithm_version TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE recommendation_user_profiles
|
||||
ADD COLUMN IF NOT EXISTS weighted_profile JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
-- Explicit recommendation feedback is separate from Emby favourites: More Like This
|
||||
-- changes discovery affinity, while Not for Me is a hard user-scoped exclusion.
|
||||
CREATE TABLE IF NOT EXISTS recommendation_actions (
|
||||
emby_user_id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('more_like_this', 'not_for_me')),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (emby_user_id, item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS recommendation_actions_user_idx
|
||||
ON recommendation_actions (emby_user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recommendation_onboarding (
|
||||
emby_user_id TEXT PRIMARY KEY,
|
||||
preferences JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS recommendation_profiles_dirty_idx
|
||||
ON recommendation_user_profiles (dirty_since)
|
||||
WHERE dirty_since IS NOT NULL;
|
||||
|
||||
@@ -14,6 +14,218 @@ import (
|
||||
// MaintenanceKey is the app_settings row backing maintenance mode.
|
||||
const MaintenanceKey = "maintenance"
|
||||
|
||||
// RequestPolicyKey controls which Emby users may send titles to Sonarr/Radarr.
|
||||
const RequestPolicyKey = "request_policy"
|
||||
|
||||
// PlaybackPolicyKey controls presentation behavior that should be adjustable without
|
||||
// shipping a new TV build.
|
||||
const PlaybackPolicyKey = "playback_policy"
|
||||
|
||||
// FeaturePolicyKey is the durable operator control plane for optional behaviour.
|
||||
// The catalogue of valid flags lives in the API; the store only persists overrides so
|
||||
// removing or renaming a feature does not strand an unreadable database row.
|
||||
const FeaturePolicyKey = "feature_policy"
|
||||
|
||||
type FeaturePolicySnapshot struct {
|
||||
Overrides map[string]bool `json:"overrides"`
|
||||
SafeMode bool `json:"safeMode"`
|
||||
Revision int64 `json:"revision"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type FeaturePolicy struct {
|
||||
Overrides map[string]bool `json:"overrides"`
|
||||
SafeMode bool `json:"safeMode"`
|
||||
Revision int64 `json:"revision"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Previous *FeaturePolicySnapshot `json:"previous,omitempty"`
|
||||
}
|
||||
|
||||
var ErrFeaturePolicyConflict = errors.New("store: feature policy revision conflict")
|
||||
|
||||
func DefaultFeaturePolicy() FeaturePolicy {
|
||||
return FeaturePolicy{Overrides: map[string]bool{}}
|
||||
}
|
||||
|
||||
func normalizeFeaturePolicy(policy FeaturePolicy) FeaturePolicy {
|
||||
if policy.Overrides == nil {
|
||||
policy.Overrides = map[string]bool{}
|
||||
}
|
||||
return policy
|
||||
}
|
||||
|
||||
func (s *Store) FeaturePolicy(ctx context.Context) (FeaturePolicy, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, FeaturePolicyKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DefaultFeaturePolicy(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return DefaultFeaturePolicy(), fmt.Errorf("store: read feature policy: %w", err)
|
||||
}
|
||||
var policy FeaturePolicy
|
||||
if err := json.Unmarshal(raw, &policy); err != nil {
|
||||
return DefaultFeaturePolicy(), fmt.Errorf("store: decode feature policy: %w", err)
|
||||
}
|
||||
return normalizeFeaturePolicy(policy), nil
|
||||
}
|
||||
|
||||
// SetFeaturePolicy preserves the prior revision inside the same durable document. This
|
||||
// gives the operator a recovery button without requiring a matching client release.
|
||||
func (s *Store) SetFeaturePolicy(
|
||||
ctx context.Context, next FeaturePolicy, expectedRevision int64,
|
||||
) (FeaturePolicy, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return FeaturePolicy{}, fmt.Errorf("store: begin feature policy write: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, FeaturePolicyKey); err != nil {
|
||||
return FeaturePolicy{}, fmt.Errorf("store: lock feature policy: %w", err)
|
||||
}
|
||||
current := DefaultFeaturePolicy()
|
||||
var currentRaw []byte
|
||||
err = tx.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, FeaturePolicyKey).Scan(¤tRaw)
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(currentRaw, ¤t); err != nil {
|
||||
return FeaturePolicy{}, fmt.Errorf("store: decode current feature policy: %w", err)
|
||||
}
|
||||
current = normalizeFeaturePolicy(current)
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return FeaturePolicy{}, fmt.Errorf("store: read current feature policy: %w", err)
|
||||
}
|
||||
if current.Revision != expectedRevision {
|
||||
return FeaturePolicy{}, ErrFeaturePolicyConflict
|
||||
}
|
||||
next = normalizeFeaturePolicy(next)
|
||||
next.Revision = current.Revision + 1
|
||||
next.UpdatedAt = time.Now().UTC()
|
||||
next.Previous = &FeaturePolicySnapshot{
|
||||
Overrides: current.Overrides, SafeMode: current.SafeMode,
|
||||
Revision: current.Revision, UpdatedAt: current.UpdatedAt,
|
||||
}
|
||||
raw, err := json.Marshal(next)
|
||||
if err != nil {
|
||||
return FeaturePolicy{}, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES ($1, $2::jsonb, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
FeaturePolicyKey, string(raw))
|
||||
if err != nil {
|
||||
return FeaturePolicy{}, fmt.Errorf("store: write feature policy: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return FeaturePolicy{}, fmt.Errorf("store: commit feature policy: %w", err)
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
const DefaultPrerollDurationMs int64 = 6_500
|
||||
|
||||
type PlaybackPolicy struct {
|
||||
PrerollEnabled bool `json:"prerollEnabled"`
|
||||
PrerollDurationMs int64 `json:"prerollDurationMs"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func DefaultPlaybackPolicy() PlaybackPolicy {
|
||||
return PlaybackPolicy{PrerollEnabled: true, PrerollDurationMs: DefaultPrerollDurationMs}
|
||||
}
|
||||
|
||||
func normalizePlaybackPolicy(policy PlaybackPolicy) PlaybackPolicy {
|
||||
if policy.PrerollDurationMs == 0 {
|
||||
policy.PrerollDurationMs = DefaultPrerollDurationMs
|
||||
}
|
||||
policy.PrerollDurationMs = max(int64(1_000), min(policy.PrerollDurationMs, int64(30_000)))
|
||||
return policy
|
||||
}
|
||||
|
||||
func (s *Store) PlaybackPolicy(ctx context.Context) (PlaybackPolicy, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, PlaybackPolicyKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return DefaultPlaybackPolicy(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return DefaultPlaybackPolicy(), fmt.Errorf("store: read playback policy: %w", err)
|
||||
}
|
||||
var policy PlaybackPolicy
|
||||
if err := json.Unmarshal(raw, &policy); err != nil {
|
||||
return DefaultPlaybackPolicy(), fmt.Errorf("store: decode playback policy: %w", err)
|
||||
}
|
||||
return normalizePlaybackPolicy(policy), nil
|
||||
}
|
||||
|
||||
func (s *Store) SetPlaybackPolicy(ctx context.Context, policy PlaybackPolicy) error {
|
||||
policy = normalizePlaybackPolicy(policy)
|
||||
policy.UpdatedAt = time.Now().UTC()
|
||||
raw, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES ($1, $2::jsonb, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
PlaybackPolicyKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write playback policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type RequestPolicy struct {
|
||||
AllowedUserIDs []string `json:"allowedUserIds"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (p RequestPolicy) Allows(userID string) bool {
|
||||
for _, allowed := range p.AllowedUserIDs {
|
||||
if allowed == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Store) RequestPolicy(ctx context.Context) (RequestPolicy, error) {
|
||||
var raw []byte
|
||||
err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, RequestPolicyKey).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return RequestPolicy{AllowedUserIDs: []string{}}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return RequestPolicy{}, fmt.Errorf("store: read request policy: %w", err)
|
||||
}
|
||||
var policy RequestPolicy
|
||||
if err := json.Unmarshal(raw, &policy); err != nil {
|
||||
return RequestPolicy{}, fmt.Errorf("store: decode request policy: %w", err)
|
||||
}
|
||||
if policy.AllowedUserIDs == nil {
|
||||
policy.AllowedUserIDs = []string{}
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetRequestPolicy(ctx context.Context, policy RequestPolicy) error {
|
||||
policy.UpdatedAt = time.Now().UTC()
|
||||
raw, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.pool.Exec(ctx, `
|
||||
INSERT INTO app_settings (key, value, updated_at)
|
||||
VALUES ($1, $2::jsonb, now())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`,
|
||||
RequestPolicyKey, string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: write request policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Maintenance is the operator switch that takes Memby down independently of Emby.
|
||||
//
|
||||
// Deliberately durable: a restart must not quietly bring the app back up while someone
|
||||
@@ -105,11 +317,11 @@ func (s *Store) NewestSession(ctx context.Context) (Session, error) {
|
||||
var sess Session
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id,
|
||||
device_name, client_version, client_protocol, last_seen_at
|
||||
device_name, client_version, client_protocol, client_capabilities, last_seen_at
|
||||
FROM sessions ORDER BY last_seen_at DESC LIMIT 1`).
|
||||
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
|
||||
&sess.ClientProtocol, &sess.LastSeenAt)
|
||||
&sess.ClientProtocol, &sess.ClientCapabilities, &sess.LastSeenAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRequestPolicyAllowsOnlyListedUsers(t *testing.T) {
|
||||
policy := RequestPolicy{AllowedUserIDs: []string{"user-2"}}
|
||||
if policy.Allows("user-1") {
|
||||
t.Fatal("unlisted user was allowed")
|
||||
}
|
||||
if !policy.Allows("user-2") {
|
||||
t.Fatal("listed user was denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackPolicyDefaultsAndClampsDuration(t *testing.T) {
|
||||
defaults := DefaultPlaybackPolicy()
|
||||
if !defaults.PrerollEnabled || defaults.PrerollDurationMs != 6_500 {
|
||||
t.Fatalf("default playback policy = %+v", defaults)
|
||||
}
|
||||
if got := normalizePlaybackPolicy(PlaybackPolicy{PrerollDurationMs: 500}); got.PrerollDurationMs != 1_000 {
|
||||
t.Fatalf("short duration = %d, want 1000", got.PrerollDurationMs)
|
||||
}
|
||||
if got := normalizePlaybackPolicy(PlaybackPolicy{PrerollDurationMs: 60_000}); got.PrerollDurationMs != 30_000 {
|
||||
t.Fatalf("long duration = %d, want 30000", got.PrerollDurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeaturePolicyDefaultsAreRecoverable(t *testing.T) {
|
||||
policy := normalizeFeaturePolicy(FeaturePolicy{})
|
||||
if policy.Overrides == nil || policy.SafeMode || policy.Revision != 0 {
|
||||
t.Fatalf("default feature policy = %+v", policy)
|
||||
}
|
||||
policy.Overrides["sonarr_preroll"] = false
|
||||
if policy.Overrides["sonarr_preroll"] {
|
||||
t.Fatal("explicit off override was not retained")
|
||||
}
|
||||
}
|
||||
+151
-91
@@ -17,19 +17,80 @@ var schema string
|
||||
|
||||
// ErrNotFound is returned when a token does not match a live session.
|
||||
var ErrNotFound = errors.New("store: session not found")
|
||||
var ErrDeviceLimit = errors.New("store: device limit reached")
|
||||
|
||||
func isNoRows(err error) bool { return errors.Is(err, pgx.ErrNoRows) }
|
||||
|
||||
type Session struct {
|
||||
TokenHash []byte
|
||||
EmbyUserID string
|
||||
EmbyToken string
|
||||
Username string
|
||||
ServerID string
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
ClientVersion string
|
||||
ClientProtocol string
|
||||
LastSeenAt time.Time
|
||||
TokenHash []byte
|
||||
EmbyUserID string
|
||||
EmbyToken string
|
||||
Username string
|
||||
ServerID string
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
ClientVersion string
|
||||
ClientProtocol string
|
||||
ClientCapabilities []string
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
type KnownUser struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
}
|
||||
|
||||
type KnownClient struct {
|
||||
DeviceName string `json:"deviceName"`
|
||||
Username string `json:"username"`
|
||||
Version string `json:"version"`
|
||||
Protocol string `json:"protocol"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
}
|
||||
|
||||
// KnownClients gives the feature console compatibility evidence without exposing
|
||||
// gateway or Emby credentials. Stale sessions remain useful rollout information.
|
||||
func (s *Store) KnownClients(ctx context.Context) ([]KnownClient, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT device_name, username, client_version, client_protocol,
|
||||
client_capabilities, last_seen_at
|
||||
FROM sessions ORDER BY last_seen_at DESC LIMIT 100`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list known clients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
clients := []KnownClient{}
|
||||
for rows.Next() {
|
||||
var client KnownClient
|
||||
if err := rows.Scan(&client.DeviceName, &client.Username, &client.Version,
|
||||
&client.Protocol, &client.Capabilities, &client.LastSeen); err != nil {
|
||||
return nil, fmt.Errorf("store: scan known client: %w", err)
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
return clients, rows.Err()
|
||||
}
|
||||
|
||||
// KnownUsers returns one entry per Emby user that has signed in to the gateway.
|
||||
func (s *Store) KnownUsers(ctx context.Context) ([]KnownUser, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT DISTINCT ON (emby_user_id) emby_user_id, username, last_seen_at
|
||||
FROM sessions
|
||||
ORDER BY emby_user_id, last_seen_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list known users: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
users := []KnownUser{}
|
||||
for rows.Next() {
|
||||
var user KnownUser
|
||||
if err := rows.Scan(&user.ID, &user.Username, &user.LastSeen); err != nil {
|
||||
return nil, fmt.Errorf("store: scan known user: %w", err)
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
@@ -112,18 +173,18 @@ func (s *Store) Migrate(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSession enforces a user's device allowance under a per-user transaction lock.
|
||||
// Re-authenticating the same stable device replaces its token and never consumes a slot.
|
||||
// CreateSession records every signed-in TV without an account-level device cap.
|
||||
// Re-authenticating the same stable device replaces its token.
|
||||
// The replaced hash is returned so its Redis entry can be invalidated immediately.
|
||||
func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int) ([]byte, int, error) {
|
||||
func (s *Store) CreateSession(ctx context.Context, sess Session) ([]byte, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("store: begin session: %w", err)
|
||||
return nil, fmt.Errorf("store: begin session: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, sess.EmbyUserID); err != nil {
|
||||
return nil, 0, fmt.Errorf("store: lock user sessions: %w", err)
|
||||
return nil, fmt.Errorf("store: lock user sessions: %w", err)
|
||||
}
|
||||
|
||||
var previousHash []byte
|
||||
@@ -132,26 +193,15 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int)
|
||||
WHERE emby_user_id = $1 AND device_id = $2`,
|
||||
sess.EmbyUserID, sess.DeviceID).Scan(&previousHash)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, 0, fmt.Errorf("store: find device session: %w", err)
|
||||
}
|
||||
existingDevice := err == nil
|
||||
|
||||
var activeClients int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT count(*) FROM sessions WHERE emby_user_id = $1`,
|
||||
sess.EmbyUserID).Scan(&activeClients); err != nil {
|
||||
return nil, 0, fmt.Errorf("store: count user sessions: %w", err)
|
||||
}
|
||||
if !existingDevice && activeClients >= maxClients {
|
||||
return nil, activeClients, ErrDeviceLimit
|
||||
return nil, fmt.Errorf("store: find device session: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO sessions (
|
||||
token_hash, emby_user_id, emby_token, username, server_id, device_id, device_name,
|
||||
client_version, client_protocol
|
||||
client_version, client_protocol, client_capabilities
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (emby_user_id, device_id) DO UPDATE SET
|
||||
token_hash = EXCLUDED.token_hash,
|
||||
emby_token = EXCLUDED.emby_token,
|
||||
@@ -160,30 +210,29 @@ func (s *Store) CreateSession(ctx context.Context, sess Session, maxClients int)
|
||||
device_name = EXCLUDED.device_name,
|
||||
client_version = EXCLUDED.client_version,
|
||||
client_protocol = EXCLUDED.client_protocol,
|
||||
client_capabilities = EXCLUDED.client_capabilities,
|
||||
last_seen_at = now()`,
|
||||
sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username,
|
||||
sess.ServerID, sess.DeviceID, sess.DeviceName, sess.ClientVersion, sess.ClientProtocol)
|
||||
sess.ServerID, sess.DeviceID, sess.DeviceName, sess.ClientVersion, sess.ClientProtocol,
|
||||
sess.ClientCapabilities)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("store: create session: %w", err)
|
||||
}
|
||||
if !existingDevice {
|
||||
activeClients++
|
||||
return nil, fmt.Errorf("store: create session: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, 0, fmt.Errorf("store: commit session: %w", err)
|
||||
return nil, fmt.Errorf("store: commit session: %w", err)
|
||||
}
|
||||
return previousHash, activeClients, nil
|
||||
return previousHash, nil
|
||||
}
|
||||
|
||||
func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) {
|
||||
var sess Session
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id,
|
||||
device_name, client_version, client_protocol, last_seen_at
|
||||
device_name, client_version, client_protocol, client_capabilities, last_seen_at
|
||||
FROM sessions WHERE token_hash = $1`, hash).
|
||||
Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
|
||||
&sess.ClientProtocol, &sess.LastSeenAt)
|
||||
&sess.ClientProtocol, &sess.ClientCapabilities, &sess.LastSeenAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Session{}, ErrNotFound
|
||||
}
|
||||
@@ -205,15 +254,16 @@ func (s *Store) Touch(ctx context.Context, hash []byte) error {
|
||||
func (s *Store) UpdateSessionClientIdentity(
|
||||
ctx context.Context,
|
||||
hash []byte,
|
||||
version, protocol string,
|
||||
version, protocol string, capabilities []string,
|
||||
) error {
|
||||
_, err := s.pool.Exec(ctx, `
|
||||
UPDATE sessions
|
||||
SET client_version = CASE WHEN $2 <> '' THEN $2 ELSE client_version END,
|
||||
client_protocol = CASE WHEN $3 <> '' THEN $3 ELSE client_protocol END,
|
||||
client_capabilities = CASE WHEN cardinality($4::text[]) > 0 THEN $4 ELSE client_capabilities END,
|
||||
last_seen_at = now()
|
||||
WHERE token_hash = $1`,
|
||||
hash, version, protocol)
|
||||
hash, version, protocol, capabilities)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -222,6 +272,66 @@ func (s *Store) DeleteSession(ctx context.Context, hash []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionsForUser returns the TVs whose gateway tokens are still active for a user.
|
||||
func (s *Store) SessionsForUser(ctx context.Context, userID string) ([]Session, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id,
|
||||
device_name, client_version, client_protocol, client_capabilities, last_seen_at
|
||||
FROM sessions
|
||||
WHERE emby_user_id = $1
|
||||
ORDER BY last_seen_at DESC, device_name`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: list user sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var sessions []Session
|
||||
for rows.Next() {
|
||||
var sess Session
|
||||
if err := rows.Scan(
|
||||
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
|
||||
&sess.ClientProtocol, &sess.ClientCapabilities, &sess.LastSeenAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan user session: %w", err)
|
||||
}
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: list user session rows: %w", err)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// DeleteUserDevice revokes one device while scoping the delete to the authenticated user.
|
||||
func (s *Store) DeleteUserDevice(ctx context.Context, userID, deviceID string) ([]byte, error) {
|
||||
var tokenHash []byte
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
DELETE FROM sessions
|
||||
WHERE emby_user_id = $1 AND device_id = $2
|
||||
RETURNING token_hash`, userID, deviceID).Scan(&tokenHash)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: delete user device: %w", err)
|
||||
}
|
||||
return tokenHash, nil
|
||||
}
|
||||
|
||||
// RenameUserDevice changes only the display name and keeps the session/token intact.
|
||||
func (s *Store) RenameUserDevice(ctx context.Context, userID, deviceID, deviceName string) error {
|
||||
tag, err := s.pool.Exec(ctx, `
|
||||
UPDATE sessions SET device_name = $3
|
||||
WHERE emby_user_id = $1 AND device_id = $2`, userID, deviceID, deviceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store: rename user device: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteIdleSessions retires tokens unused for longer than idle, returning how many went.
|
||||
func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int64, error) {
|
||||
tag, err := s.pool.Exec(ctx,
|
||||
@@ -232,53 +342,3 @@ func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// TrimSessionsToLimit brings data created under an older, more generous policy back
|
||||
// within the current allowance. The most recently active devices survive.
|
||||
func (s *Store) TrimSessionsToLimit(ctx context.Context, maxClients int) ([]Session, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
WITH ranked AS (
|
||||
SELECT token_hash,
|
||||
row_number() OVER (
|
||||
PARTITION BY emby_user_id
|
||||
ORDER BY last_seen_at DESC, created_at DESC, token_hash DESC
|
||||
) AS device_rank
|
||||
FROM sessions
|
||||
),
|
||||
retired AS (
|
||||
DELETE FROM sessions current
|
||||
USING ranked
|
||||
WHERE current.token_hash = ranked.token_hash
|
||||
AND ranked.device_rank > $1
|
||||
RETURNING current.token_hash, current.emby_user_id, current.emby_token,
|
||||
current.username, current.server_id, current.device_id,
|
||||
current.device_name, current.client_version, current.client_protocol,
|
||||
current.last_seen_at
|
||||
)
|
||||
SELECT token_hash, emby_user_id, emby_token, username, server_id,
|
||||
device_id, device_name, client_version, client_protocol, last_seen_at
|
||||
FROM retired`,
|
||||
maxClients,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: trim sessions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var retired []Session
|
||||
for rows.Next() {
|
||||
var sess Session
|
||||
if err := rows.Scan(
|
||||
&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username,
|
||||
&sess.ServerID, &sess.DeviceID, &sess.DeviceName, &sess.ClientVersion,
|
||||
&sess.ClientProtocol, &sess.LastSeenAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("store: scan trimmed session: %w", err)
|
||||
}
|
||||
retired = append(retired, sess)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store: trim sessions rows: %w", err)
|
||||
}
|
||||
return retired, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user