App v0.2.26 and gateway 0.1.20

Client: seek controls, Bazarr subtitle download and cast panel in the
player; MDBList ratings strip; episode and schedule detail pages; series
pace estimate; what's new panel; install-permission onboarding step;
synced per-profile preferences; Emby outage banner.

Gateway: rebuilt admin console (one fragment per page), preference
history and restore, merged Continue Watching, Emby health probe,
subtitle selection and Bazarr download, structured request logging with
per-request identity, and embedded build version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
+14 -2
View File
@@ -1,3 +1,15 @@
.git
*.md
# The build context is uploaded to the Docker daemon in full before the first
# instruction runs, so anything matched here is bandwidth and time paid on every
# build. The gateway's source is ~1.2MB; keep it that way.
#
# A stray GOCACHE is the failure this file exists for. Running `go test` on
# Windows with GOCACHE pointed inside server/ leaves several hundred megabytes
# of hex-sharded cache here — invisible to git (it is in .gitignore) and so
# noticed only as a deployment that suddenly takes minutes.
.tmp-go-cache/
.tmp-*/
bin/
.git
.gitignore
*.md
+59 -15
View File
@@ -57,14 +57,37 @@ It deploys the **local working tree**, including uncommitted server changes.
## Logs
The server writes one compact, structured text line per event, designed for
`docker compose logs -f server`. At the default `MEMBY_LOG_LEVEL=INFO`, successful
health checks, live maintenance polls and artwork requests are hidden; warnings and
failures from those routes are still shown.
The server writes one aligned line per event, designed for
`docker compose logs -f server` and for the admin page's live log:
Set `MEMBY_LOG_LEVEL=DEBUG` temporarily and recreate the server container when those
high-frequency requests are useful during diagnosis. Library imports log their start,
progress after each 500-item page, and final counts and duration.
```
2026-08-03 11:33:40 INFO playback requested component=playback user=matt device="Living room" client=0.1.60 title="Severance Good News About Hell" item=184223 type=Episode play_method=DirectStream resume=12m0s runtime=57m0s version=0.1.0
2026-08-03 11:33:40 INFO request component=home user=matt device="Living room" client=0.1.60 protocol=1 method=GET path=/v1/home status=200 duration=412ms cache=miss version=0.1.0
```
The timestamp is a column rather than a `time=` field, and the fields are always in the
same order: **who and where** first (`component`, `user`, `device`, `client`), then what
the event is about, with `version` (the gateway build) and `error` last. `MEMBY_LOG_FORMAT`
switches the whole stream to `logfmt` (slog's own text) or `json` for a collector.
Every line from a request carries the viewer, the television, the app build and the
`component` — the part of the app the call came from, derived from the route, so it is
right even for an APK too old to report anything about itself.
Beyond the per-request line, these are logged as events in their own right: sign-in,
sign-out and rejected sign-ins; a device removed or renamed from another TV; what
somebody asked to play and what happened to it (`playback requested`, `playback
started`, `playback stopped` with how much was watched, `next episode resolved`); an
update offered or forced; a media request; maintenance and feature-policy changes; and
library imports (start, progress per 500-item page, final counts and duration).
At the default `MEMBY_LOG_LEVEL=INFO`, successful health checks, live maintenance polls,
artwork requests, search terms and ten-second playback progress reports are hidden;
warnings and failures from those routes are still shown. Set `MEMBY_LOG_LEVEL=DEBUG`
temporarily and recreate the server container when they are useful during diagnosis.
The build that wrote a line is `internal/buildinfo/VERSION`, embedded at compile time —
bump it with a meaningful server change. It is also on `/healthz` and in the admin rail.
## API
@@ -112,7 +135,6 @@ three fixed rows repeated flat for the client's offline cache:
{
"rows": [
{"id": "continue", "title": "Continue Watching", "kind": "continue", "items": [...]},
{"id": "next-up", "title": "Next Up", "kind": "nextup", "items": [...]},
{"id": "for-you:pick-up", "title": "Pick this show up again", "kind": "for-you", "items": [...]},
{"id": "sonarr-airing-today", "title": "Shows airing in the next 5 days", "kind": "schedule", "items": [...]},
{"id": "favorites", "title": "Favourites", "kind": "favorites", "items": [...]},
@@ -130,6 +152,11 @@ three fixed rows repeated flat for the client's offline cache:
The TV renders whatever arrives, so a new row ships without an app release. `kind` picks
the card shape; an unrecognised kind falls back to poster cards rather than being dropped.
There is no Next Up row: those episodes are interleaved into Continue Watching, most
recently watched first, so the episode after one that just finished is the first card
rather than a card in another row. The response still carries a flat `nextUp` array for
televisions running a build that predates the merge.
The user's own section toggles still hide the three fixed rows, but never rows the server
invented — nobody opted out of a row that did not exist when they last opened Settings.
@@ -186,6 +213,14 @@ stable `(serverId, sessionId)` key. Incremental passes run every five minutes an
after two unchanged pages (at most ten); a daily full pass catches late or out-of-order
updates and reconciles deletions. Imports are idempotent.
Whether a pass is owed is decided by the timestamps recorded in `app_settings`, not by
how long this process has been up: the scheduler ticks, asks, and usually does nothing.
That is what makes the cadence survive a restart. Driving it from tickers alone meant a
redeploy or a bounced container started the interval again and imported immediately, so a
gateway that restarted often imported far more often than configured — and, in the other
direction, one restarted daily never reached its 24-hour full reconciliation at all. The
startup pass follows the same rule; `/admin` still forces either kind on demand.
Postgres retains the compact source sessions, one derived profile per enabled Emby user,
and at most 750 ranked Movie/Series candidates per user. The background builder reads Tracearr's
public users endpoint and exact-matches usernames against Emby's user list. Unmatched
@@ -204,7 +239,7 @@ The endpoint remains one indexed PostgreSQL read plus JSON enrichment. Tracearr
stay frequent but do not rebuild pools. A session's first transition to stopped/completed
dirties only its matched user, and all pools rebuild once daily at the configured local
off-peak hour. A stored algorithm version triggers a one-time startup migration only when
ranking behavior changes. A cold or failed rebuild uses the original live Tracearr/Emby
ranking behaviour changes. A cold or failed rebuild uses the original live Tracearr/Emby
path, so persistence cannot blank the area.
Every returned item is enriched with `MembyRecommendationReason` and
@@ -293,6 +328,14 @@ and the current 30-minute Emby-verified session. The old admin cookie therefore
bypass the gate after the browser session expires. Scripts may continue to use
`Authorization: Bearer <MEMBY_ADMIN_TOKEN>` without a browser session.
That 30 minutes is idle time, not a hard limit: opening an admin page, making any change,
or reading one while interacting with it slides the expiry forward once it is inside the
last fifteen minutes. What deliberately does **not** extend it is the page's own status
poll — a console left open on a second monitor still times out, which is the whole point
of the TTL. The page marks its own requests with `X-Memby-Admin-Active` when there has
been interaction in the last five minutes, and on a 401 it reloads, so an expiry lands as
the sign-in form with `next` pointing back at the page rather than as an error banner.
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/admin/` | The page |
@@ -325,9 +368,10 @@ The **Movie ratings** admin page enables MDBList, stores its API key only in the
by default. Movie detail pages request `/v1/items/{id}/ratings` independently of essential
Emby metadata; missing provider IDs, MDBList errors, invalid or unavailable values all
produce an empty successful response. Successful MDBList responses are cached in Redis for
24 hours before the configured source filter is applied, so changing visible sources does
not consume more MDBList quota. The API key is never returned by either the TV or admin
status APIs.
24 hours and persisted in Postgres for seven days before the configured source filter is
applied, so restarts, multiple TVs, and changing visible sources do not consume more
MDBList quota. If MDBList is temporarily unavailable, the most recently persisted response
is served stale. The API key is never returned by either the TV or admin status APIs.
## Library import
@@ -544,14 +588,14 @@ can review and revoke signed-in TVs from the app's Settings screen.
| `MEMBY_GOMEMLIMIT` | `384MiB` | Compose value passed to Go as `GOMEMLIMIT` |
| `MEMBY_SERVER_MEMORY_LIMIT` | `512m` | Compose hard memory ceiling for the server container |
| `MEMBY_TIMEZONE` | `Pacific/Auckland` | Local day and time labels for schedule rows |
| `MEMBY_CLIENT_NAME` | `Memby` | Shown in Emby's device list |
| `MEMBY_CLIENT_NAME` | `MbyATV` | Client name sent to Emby; must match the app's direct path |
| `MEMBY_HOME_TTL` | `60s` | Also `MEMBY_ITEM_TTL`, `MEMBY_SEARCH_TTL`, `MEMBY_SCREENSAVER_TTL` |
| `MEMBY_RECOMMEND_TTL` | `24h` | How long computed recommendation rows stay warm |
| `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild |
| `MEMBY_TRACEARR_URL` / `MEMBY_TRACEARR_API_KEY` | *empty* | Read-only Tracearr public API; both values are required |
| `MEMBY_TRACEARR_SERVER_ID` | *empty* | Optional server scope when Tracearr monitors several servers |
| `MEMBY_TRACEARR_SYNC_INTERVAL` | `5m` | Incremental session import cadence; `0` disables scheduling |
| `MEMBY_TRACEARR_FULL_INTERVAL` | `24h` | Full reconciliation cadence |
| `MEMBY_TRACEARR_SYNC_INTERVAL` | `5m` | Minimum age of the last incremental import before another is owed; `0` disables incremental passes |
| `MEMBY_TRACEARR_FULL_INTERVAL` | `24h` | Minimum age of the last full reconciliation before another is owed; `0` disables full passes |
| `MEMBY_FOR_YOU_MIN_REBUILD_AGE` | `24h` | Safety bound for non-forced pool rebuilds |
| `MEMBY_FOR_YOU_REFRESH_INTERVAL` | `24h` | Acceptable prepared-pool staleness |
| `MEMBY_FOR_YOU_REBUILD_HOUR` | `4` | Local hour (023) for the daily prepared rebuild |
+27 -6
View File
@@ -17,6 +17,8 @@ import (
"time"
"github.com/ponzischeme89/memby/server/internal/api"
"github.com/ponzischeme89/memby/server/internal/bazarr"
"github.com/ponzischeme89/memby/server/internal/buildinfo"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
@@ -46,7 +48,12 @@ func main() {
logLevel := logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL"))
logCapacity := logging.ParseCapacity(os.Getenv("MEMBY_LOG_BUFFER_CAPACITY"), 5_000)
log, events := logging.NewBuffered(os.Stdout, logLevel, logCapacity)
logFormat := logging.ParseFormat(os.Getenv("MEMBY_LOG_FORMAT"))
log, events := logging.NewBuffered(os.Stdout, logLevel, logCapacity, logFormat)
// Every line names the build that wrote it. A gateway is deployed from a working
// tree, often while a television is running an older app, so "which server said
// this" is a real question that a reader should never have to scroll for.
log = log.With("version", buildinfo.Version())
if err := run(log, events); err != nil {
log.Error("fatal", "error", err)
@@ -102,8 +109,16 @@ func run(log *slog.Logger, events *logging.Buffer) error {
radarrClient = radarr.New(cfg.RadarrURL, cfg.RadarrAPIKey, cfg.UpstreamTimeout)
log.Info("radarr integration enabled", "url", cfg.RadarrURL)
}
// Bazarr gets its own timeout rather than UpstreamTimeout: a manual search queries
// live subtitle providers and legitimately takes tens of seconds, and cutting it short
// reads to the viewer as "no subtitles found" rather than as a timeout.
var bazarrClient *bazarr.Client
if cfg.BazarrURL != "" {
bazarrClient = bazarr.New(cfg.BazarrURL, cfg.BazarrAPIKey, cfg.BazarrTimeout)
log.Info("bazarr integration enabled", "url", cfg.BazarrURL)
}
recommender := recommend.NewEngine(embyClient, log)
recommender := recommend.NewEngine(embyClient, log.With("component", "recommendations"))
if cfg.RecommendationWeights != "" {
_ = json.Unmarshal([]byte(cfg.RecommendationWeights), &recommender.WeightedConfig)
}
@@ -127,11 +142,11 @@ func run(log *slog.Logger, events *logging.Buffer) error {
UserID: cfg.SyncUserID,
Token: cfg.SyncAPIKey,
DeviceID: "memby-gateway-sync",
}, log)
}, log.With("component", "library"))
var forYouService *foryou.Service
if tracearrClient != nil {
forYouService = foryou.New(
st, tracearrClient, recommender, log,
st, tracearrClient, recommender, log.With("component", "for-you"),
cfg.ForYouMinRebuildAge, cfg.ForYouRefreshInterval,
)
forYouService.ConfigureTimeContext(cfg.SonarrLocation)
@@ -149,6 +164,7 @@ func run(log *slog.Logger, events *logging.Buffer) error {
ForYou: forYouService,
Sonarr: sonarrClient,
Radarr: radarrClient,
Bazarr: bazarrClient,
MDBList: mdblistClient,
Syncer: syncer,
Log: log,
@@ -197,7 +213,11 @@ func run(log *slog.Logger, events *logging.Buffer) error {
)
go func() {
importCtx, cancel := context.WithTimeout(ctx, cfg.SyncTimeout)
if _, err := forYouService.Import(importCtx, false); err != nil {
// Only if one is actually owed. An unconditional startup import made every
// redeploy or container bounce a fresh pass over Tracearr's history.
if _, _, err := forYouService.ImportIfDue(
importCtx, cfg.TracearrSyncInterval, cfg.TracearrFullInterval,
); err != nil {
cancel()
log.Warn("startup Tracearr import failed", "error", err)
return
@@ -226,9 +246,10 @@ func run(log *slog.Logger, events *logging.Buffer) error {
errCh := make(chan error, 1)
go func() {
log.Info("server ready",
log.Info("gateway ready",
"listen", cfg.ListenAddr,
"emby", cfg.EmbyURL,
"protocol", api.ProtocolVersion,
"sync_every", cfg.SyncInterval,
)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+147 -43
View File
@@ -14,15 +14,18 @@ import (
"time"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/buildinfo"
"github.com/ponzischeme89/memby/server/internal/library"
"github.com/ponzischeme89/memby/server/internal/store"
)
//go:embed admin.html
var adminPage []byte
const adminCookieName = "memby_admin"
// adminActivityHeader is how the console distinguishes its own poll from a request an
// operator caused. It is advice, not authority: a page that sets it always already holds
// a valid session, so the only thing it can extend is its own sign-in.
const adminActivityHeader = "X-Memby-Admin-Active"
// adminRoutes is the operator interface: library imports, the maintenance switch, and
// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
// left exposed by accident.
@@ -31,12 +34,25 @@ func (s *Server) adminRoutes() http.Handler {
mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
mux.HandleFunc("GET /admin/{page}", s.handleAdminPage)
// One person's own page. It is a path rather than a query string so it can be linked,
// bookmarked and returned to after a sign-in, like every other page here.
mux.HandleFunc("GET /admin/accounts/{userID}", s.handleAdminAccountPage)
// The settings history is its own page rather than a fifth card on the account: it is
// a table with a row per change and an action per row, and it is read when something
// has gone wrong rather than as part of ordinary account admin.
mux.HandleFunc("GET /admin/accounts/{userID}/settings", s.handleAdminSettingsHistoryPage)
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
mux.Handle("GET /admin/api/accounts", s.adminAuth(s.handleAdminAccounts))
mux.Handle("PUT /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminRenameDevice))
mux.Handle("DELETE /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminDeleteDevice))
mux.Handle("DELETE /admin/api/accounts/{userID}/sessions", s.adminAuth(s.handleAdminDeleteAccount))
mux.Handle("PUT /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminPushPreferences))
mux.Handle("DELETE /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminResetPreferences))
mux.Handle("GET /admin/api/accounts/{userID}/preferences/history", s.adminAuth(s.handleAdminPreferenceHistory))
mux.Handle("POST /admin/api/accounts/{userID}/preferences/revisions/{revision}/restore",
s.adminAuth(s.handleAdminRestorePreferences))
mux.Handle("DELETE /admin/api/accounts/{userID}/recommendations", s.adminAuth(s.handleAdminResetRecommendations))
mux.Handle("PUT /admin/api/accounts/{userID}/recommendations/prompt", s.adminAuth(s.handleAdminPromptRecommendations))
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))
@@ -44,6 +60,7 @@ func (s *Server) adminRoutes() http.Handler {
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/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
@@ -54,18 +71,12 @@ func (s *Server) adminRoutes() http.Handler {
return mux
}
var adminPages = map[string]bool{
"accounts": true, "library": true, "recommendations": true, "requests": true,
"features": true, "playback": true, "maintenance": true, "updates": true, "engagement": true,
"ratings": 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)
http.Redirect(w, r, "/admin/overview", http.StatusFound)
}
type adminRuntimeStatus struct {
@@ -132,24 +143,73 @@ func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
writeError(w, http.StatusUnauthorized, "invalid admin token")
return
}
if browser && operatorPresent(r) {
s.renewInstallerSession(w, r)
}
h(w, r)
})
}
// operatorPresent reports whether a request came from somebody at the keyboard rather
// than from the console's own status poll. Only these extend the sign-in: the dashboard
// refreshes itself on a timer, so renewing on any request at all would leave a tab
// abandoned on a second monitor signed in forever. A mutation is a click by definition;
// for reads the page says so itself, setting the header only while there has been recent
// interaction with it.
func operatorPresent(r *http.Request) bool {
return r.Method != http.MethodGet ||
strings.TrimSpace(r.Header.Get(adminActivityHeader)) == "1"
}
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
// A hidden page is reachable only through the route that knows how to address it: an
// account page with nobody to be about is not a page.
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)
s.serveAdminPage(w, r, page, "/admin/"+page)
}
// The account page is served from its own route because the identity is in the path. The
// sign-in returns to /admin/accounts rather than to this URL: cleanInstallerDestination only
// admits the pages it can name, and a person's id is not one of them.
func (s *Server) handleAdminAccountPage(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(r.PathValue("userID")) == "" {
http.NotFound(w, r)
return
}
s.serveAdminPage(w, r, "account", "/admin/accounts")
}
// Hidden for the same reason the account page is: a settings history with nobody to be
// about is not a page, so the identity is in the path and a sign-in returns to the account
// list rather than here.
func (s *Server) handleAdminSettingsHistoryPage(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(r.PathValue("userID")) == "" {
http.NotFound(w, r)
return
}
s.serveAdminPage(w, r, "settings-history", "/admin/accounts")
}
func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, next string) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
body, ok := adminRendered[page]
if !ok {
http.NotFound(w, r)
return
}
if !s.validInstallerSession(r) {
s.renderAccessLogin(w, r, "", http.StatusOK, next)
return
}
// Opening a page is somebody at the keyboard, so it starts the clock again.
s.renewInstallerSession(w, r)
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
@@ -163,10 +223,13 @@ 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)
_, _ = w.Write(body)
}
type adminStatus struct {
// ServerVersion is what the page's footer reports. An operator reading the live log
// needs to know which build wrote it, and the page is the one place that is asked.
ServerVersion string `json:"serverVersion"`
Maintenance store.Maintenance `json:"maintenance"`
UpdatePolicy appupdate.Policy `json:"updatePolicy"`
Library store.LibraryStats `json:"library"`
@@ -190,13 +253,13 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
stats, err := s.store.LibraryStats(ctx)
if err != nil {
s.log.Error("library stats failed", "error", err)
s.loggerFor(ctx).Error("library stats failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read library stats")
return
}
runs, err := s.store.RecentSyncRuns(ctx, 10)
if err != nil {
s.log.Error("sync history failed", "error", err)
s.loggerFor(ctx).Error("sync history failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read sync history")
return
}
@@ -206,23 +269,24 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
if s.forYou != nil {
forYouStats, err = s.forYou.Stats(ctx)
if err != nil {
s.log.Warn("For You stats failed", "error", err)
s.loggerFor(ctx).Warn("For You stats failed", "error", err)
}
forYouRunning = s.forYou.Running()
}
requestPolicy, err := s.store.RequestPolicy(ctx)
if err != nil {
s.log.Warn("request policy read failed", "error", err)
s.loggerFor(ctx).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)
s.loggerFor(ctx).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)
s.loggerFor(ctx).Warn("known clients read failed", "error", err)
}
writeJSON(w, http.StatusOK, adminStatus{
ServerVersion: buildinfo.Version(),
Maintenance: s.maintenance.get(),
UpdatePolicy: s.updatePolicy.get(),
Library: stats,
@@ -235,21 +299,29 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
PlaybackPolicy: func() store.PlaybackPolicy {
policy, policyErr := s.store.PlaybackPolicy(ctx)
if policyErr != nil {
s.log.Warn("playback policy read failed", "error", policyErr)
s.loggerFor(ctx).Warn("playback policy read failed", "error", policyErr)
return store.DefaultPlaybackPolicy()
}
return policy
}(),
Features: featurePayload(s.currentFeaturePolicy(ctx), membyProtocolVersion),
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
RequestUsers: requestUsers,
Clients: clients,
MDBList: func() mdblistAdminSettings {
settings, settingsErr := s.store.MDBListSettings(ctx)
if settingsErr != nil {
s.log.Warn("MDBList settings read failed", "error", settingsErr)
s.loggerFor(ctx).Warn("MDBList settings read failed", "error", settingsErr)
settings = store.DefaultMDBListSettings()
}
return publicMDBListSettings(settings)
public := publicMDBListSettings(settings)
// How much of the catalogue is already stored is the only way to see whether
// the integration is still spending the operator's daily allowance.
if total, stale, statsErr := s.store.MediaRatingsStats(
ctx, time.Now().Add(-ratingsRefreshInterval),
); statsErr == nil {
public.CachedTitles, public.StaleTitles = total, stale
}
return public
}(),
SonarrReady: s.sonarr != nil,
RadarrReady: s.radarr != nil,
@@ -261,6 +333,8 @@ type mdblistAdminSettings struct {
APIKeyConfigured bool `json:"apiKeyConfigured"`
Sources []string `json:"sources"`
AvailableSources []string `json:"availableSources"`
CachedTitles int `json:"cachedTitles"`
StaleTitles int `json:"staleTitles"`
}
type mdblistSettingsRequest struct {
@@ -321,16 +395,17 @@ func (s *Server) handleAdminMDBListSettings(w http.ResponseWriter, r *http.Reque
}
next := store.MDBListSettings{Enabled: req.Enabled, APIKey: apiKey, Sources: sources}
if err := s.store.SetMDBListSettings(r.Context(), next); err != nil {
s.log.Error("MDBList settings write failed", "error", err)
s.loggerFor(r.Context()).Error("MDBList settings write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save MDBList settings")
return
}
s.forgetMDBListSettings()
stored, err := s.store.MDBListSettings(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not reload MDBList settings")
return
}
s.log.Info("MDBList settings changed", "enabled", stored.Enabled, "sources", len(stored.Sources),
s.loggerFor(r.Context()).Info("MDBList settings changed", "enabled", stored.Enabled, "sources", len(stored.Sources),
"api_key_configured", stored.APIKey != "")
writeJSON(w, http.StatusOK, publicMDBListSettings(stored))
}
@@ -354,7 +429,7 @@ func (s *Server) handleAdminPlaybackPolicy(w http.ResponseWriter, r *http.Reques
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)
s.loggerFor(r.Context()).Error("playback policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save playback policy")
return
}
@@ -363,7 +438,7 @@ func (s *Server) handleAdminPlaybackPolicy(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusInternalServerError, "could not reload playback policy")
return
}
s.log.Info("playback policy changed", "preroll_enabled", stored.PrerollEnabled,
s.loggerFor(r.Context()).Info("playback policy changed", "preroll_enabled", stored.PrerollEnabled,
"preroll_duration_ms", stored.PrerollDurationMs)
writeJSON(w, http.StatusOK, stored)
}
@@ -403,11 +478,11 @@ func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request
}
policy := store.RequestPolicy{AllowedUserIDs: allowed}
if err := s.store.SetRequestPolicy(r.Context(), policy); err != nil {
s.log.Error("request policy write failed", "error", err)
s.loggerFor(r.Context()).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))
s.loggerFor(r.Context()).Info("media request access changed", "users", len(allowed))
writeJSON(w, http.StatusOK, policy)
}
@@ -464,15 +539,15 @@ func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request)
}
if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
s.log.Error("update policy write failed", "error", err)
s.loggerFor(r.Context()).Error("update policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save the update policy")
return
}
if err := s.LoadUpdatePolicy(r.Context()); err != nil {
s.log.Warn("update policy reload failed", "error", err)
s.loggerFor(r.Context()).Warn("update policy reload failed", "error", err)
}
s.log.Info("update policy changed",
s.loggerFor(r.Context()).Info("update policy changed",
"enabled", policy.Enabled, "latest", policy.LatestVersion, "minimum", policy.MinimumVersion)
writeJSON(w, http.StatusOK, s.updatePolicy.get())
}
@@ -502,7 +577,10 @@ func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.SyncTimeout)
defer cancel()
if _, err := s.syncer.Sync(ctx, req.Kind, "manual"); err != nil {
s.log.Error("manual sync failed", "kind", req.Kind, "error", err)
// Detached from the request on purpose, so the identity in the request
// context is gone by now; name the area explicitly instead.
s.log.Error("manual sync failed",
"component", "admin", "kind", req.Kind, "error", err)
}
}()
@@ -542,12 +620,14 @@ func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
if req.Action != "rebuild-all" {
_, err := s.forYou.Import(ctx, req.Action == "full-import")
if err != nil {
s.log.Error("manual Tracearr import failed", "action", req.Action, "error", err)
s.log.Error("manual Tracearr import failed",
"component", "admin", "action", req.Action, "error", err)
return
}
}
if err := s.forYou.RebuildAll(ctx, true); err != nil {
s.log.Error("manual For You rebuild failed", "action", req.Action, "error", err)
s.log.Error("manual For You rebuild failed",
"component", "admin", "action", req.Action, "error", err)
}
}()
writeJSON(w, http.StatusAccepted, map[string]string{
@@ -555,6 +635,30 @@ func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
})
}
// handleAdminDeploymentAlert announces that this gateway is about to be replaced.
//
// It takes no body and says the same thing every time: the caller is `deploy-server.ps1`,
// which runs before the old container is stopped, and a deployment script has no business
// deciding what appears over somebody's film. It is deliberately outside the maintenance
// switch — a deployment is not maintenance mode, and the point is to say so while the app
// is still working.
func (s *Server) handleAdminDeploymentAlert(w http.ResponseWriter, r *http.Request) {
if s.cache == nil {
// Alerts live in Redis; with none there is nowhere to publish to, and the deploy
// must be told rather than believing every TV was warned.
writeError(w, http.StatusServiceUnavailable, "alerts are unavailable without Redis")
return
}
s.AnnounceDeployment(r.Context())
s.loggerFor(r.Context()).Warn("deployment announced to clients",
"component", "admin", "window", deploymentAlertWindow.String())
writeJSON(w, http.StatusAccepted, map[string]string{
"status": "announced",
"title": deploymentAlertTitle,
"message": deploymentAlertMessage,
})
}
type maintenanceRequest struct {
Enabled bool `json:"enabled"`
Message string `json:"message"`
@@ -569,15 +673,15 @@ func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request)
state := store.Maintenance{Enabled: req.Enabled, Message: strings.TrimSpace(req.Message)}
if err := s.store.SetMaintenance(r.Context(), state); err != nil {
s.log.Error("maintenance write failed", "error", err)
s.loggerFor(r.Context()).Error("maintenance write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not update maintenance mode")
return
}
if err := s.LoadMaintenance(r.Context()); err != nil {
s.log.Warn("maintenance reload failed", "error", err)
s.loggerFor(r.Context()).Warn("maintenance reload failed", "error", err)
}
s.log.Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message)
s.loggerFor(r.Context()).Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message)
writeJSON(w, http.StatusOK, s.maintenance.get())
}
@@ -587,7 +691,7 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
stats, err := s.store.RowStats(r.Context(), since)
if err != nil {
s.log.Error("row stats failed", "error", err)
s.loggerFor(r.Context()).Error("row stats failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read analytics")
return
}
File diff suppressed because it is too large Load Diff
+486
View File
@@ -0,0 +1,486 @@
/* One vocabulary for the whole console.
Every screen is built from the components below — card, tile, field, tag, table, list.
A page that needs a look of its own is a missing component, not a licence for a style
attribute: there are no inline styles in the page fragments, on purpose.
The palette is the television app's, raised a step: the console is read on a lit desk
rather than across a dark room, and at the app's near-black the surfaces and the page
behind them were the same colour to anything but a calibrated screen — a card had a
hairline and nothing else saying where it began. Everything is lifted together so the
relationships hold, and the borders lift with them.
The accent is still spent only on what is live, selected or primary. Beside it are three
secondary tones with no verdict attached — info (blue), note (violet), data (teal) —
which is what lets a page distinguish one kind of thing from another without every
coloured element reading as a warning. Green means good, amber means look, red means
wrong; the other three mean nothing at all, which is the point. */
:root {
color-scheme: dark;
--bg: #101418;
--surface: #171d23;
--surface-lift: #1f262d;
--surface-hi: #252d35;
--line: #29323a;
--line-soft: #202830;
--text: #eef2f5;
--muted: #97a2ab;
--quiet: #717c86;
--accent: #52b54b;
--accent-ink: #8fe287;
--accent-wash: rgba(82, 181, 75, .14);
--danger: #e5534b;
--danger-ink: #ff9b94;
--danger-wash: rgba(229, 83, 75, .14);
--warn: #e0ad4e;
--warn-ink: #f2cb78;
--warn-wash: rgba(239, 196, 107, .14);
--info: #4f9cd8;
--info-ink: #93cbef;
--info-wash: rgba(79, 156, 216, .15);
--note: #a07ce8;
--note-ink: #bda1f5;
--note-wash: rgba(160, 124, 232, .15);
--data: #3fc2b6;
--data-ink: #6fdcd0;
--data-wash: rgba(63, 194, 182, .14);
--rail: 232px;
--radius: 12px;
--radius-sm: 8px;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent-ink); }
.skip {
position: absolute; left: -9999px; top: 8px; z-index: 60;
padding: 8px 14px; border-radius: var(--radius-sm);
background: var(--surface-lift); color: var(--text); text-decoration: none;
}
.skip:focus { left: 8px; }
/* ---------- rail ---------- */
/* The rail sits a shade under the page rather than level with it. With everything lifted
together, one border was no longer enough to say where the navigation stopped and the
screen began. */
.rail {
position: fixed; inset: 0 auto 0 0; z-index: 20; width: var(--rail);
display: flex; flex-direction: column;
padding: 20px 10px 12px;
background: #0c1013; border-right: 1px solid var(--line);
}
.rail-scroll { flex: 1; min-height: 0; overflow-y: auto; padding-bottom: 12px; }
.rail-brand {
display: flex; align-items: center; gap: 11px;
padding: 0 10px 18px; color: var(--text); text-decoration: none;
}
.rail-mark {
display: grid; place-items: center; width: 30px; height: 30px; flex: 0 0 30px;
border-radius: 9px; background: var(--accent); color: #06240a;
font-size: 17px; font-weight: 800; letter-spacing: -.06em;
}
.rail-brand-copy b { display: block; font-size: 15px; line-height: 1.15; }
.rail-brand-copy span {
display: block; margin-top: 1px; color: var(--quiet);
font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
}
.rail-group {
margin: 16px 12px 6px; color: var(--quiet);
font-size: 10px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase;
}
.rail-nav { display: grid; gap: 2px; }
.rail-link {
position: relative;
display: flex; align-items: center; gap: 11px; min-height: 36px;
padding: 0 12px; border-radius: var(--radius-sm);
color: var(--muted); text-decoration: none; font-size: 13.5px; font-weight: 500;
}
.rail-link svg {
width: 17px; height: 17px; flex: 0 0 17px;
fill: none; stroke: currentColor; stroke-width: 1.7;
stroke-linecap: round; stroke-linejoin: round;
}
.rail-link:hover { color: var(--text); background: var(--surface); }
.rail-link:hover svg { stroke: var(--accent-ink); }
.rail-link.active { color: var(--text); background: var(--accent-wash); font-weight: 600; }
.rail-link.active svg { stroke: var(--accent); }
/* The page you are on is the one thing in the rail worth marking twice: a wash reads at a
glance, the bar survives being looked at sideways on a poor monitor. */
.rail-link.active::before {
content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: var(--accent);
}
.rail-foot {
display: flex; align-items: center; gap: 9px;
padding: 12px 12px 0; border-top: 1px solid var(--line);
}
.rail-foot-copy { min-width: 0; }
.rail-foot-copy b { display: block; font-size: 12px; font-weight: 600; }
.rail-foot-copy span { display: block; color: var(--quiet); font-size: 11px; }
/* ---------- page ---------- */
.page {
width: calc(100% - var(--rail)); margin-left: var(--rail);
padding: 26px clamp(18px, 2.6vw, 40px) 56px;
display: grid; gap: 16px; align-content: start;
}
.page-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; flex-wrap: wrap; padding-bottom: 2px;
}
/* The heading wears the same mark as the rail entry that reached it — the one thing on the
page confirming which of twelve screens is open, for somebody who arrived by a link
rather than by the menu. A hidden page (an account, its settings history) has no mark of
its own and the template leaves it out, which is also what keeps the heading safe for the
two pages that rewrite it from their own data. */
.page-head h1 {
margin: 0; font-size: 25px; line-height: 1.2; letter-spacing: -.02em; font-weight: 650;
display: flex; align-items: center; gap: 12px;
}
.page-mark { width: 34px; height: 34px; border-radius: 10px; }
.page-mark .ico { width: 19px; height: 19px; flex: 0 0 19px; }
.page-head p { margin: 5px 0 0; color: var(--muted); font-size: 14px; max-width: 62ch; }
.crumb {
display: inline-flex; align-items: center; gap: 6px;
color: var(--muted); text-decoration: none; font-size: 13px;
}
.crumb:hover { color: var(--text); }
/* ---------- icons ---------- */
/* Every icon on the console is one stroked path on a 24×24 grid, drawn in the current
colour — the rail's marks, a card's heading, a tile, a tag. `Admin.icon` is the only
place a path is written down, and `data-icon` on any element is how a fragment asks for
one, so a page never carries SVG markup of its own. */
.ico {
width: 16px; height: 16px; flex: 0 0 16px;
fill: none; stroke: currentColor; stroke-width: 1.7;
stroke-linecap: round; stroke-linejoin: round;
}
/* An icon in a tinted plate. The tint is the label: it says which of the console's areas a
card belongs to before its heading has been read. */
.glyph {
display: grid; place-items: center; flex: 0 0 auto;
width: 26px; height: 26px; border-radius: 8px;
background: var(--surface-hi); color: var(--muted);
}
.glyph[data-tone=accent] { background: var(--accent-wash); color: var(--accent-ink); }
.glyph[data-tone=ok] { background: var(--accent-wash); color: var(--accent-ink); }
.glyph[data-tone=bad] { background: var(--danger-wash); color: var(--danger-ink); }
.glyph[data-tone=warn] { background: var(--warn-wash); color: var(--warn-ink); }
.glyph[data-tone=info] { background: var(--info-wash); color: var(--info-ink); }
.glyph[data-tone=note] { background: var(--note-wash); color: var(--note-ink); }
.glyph[data-tone=data] { background: var(--data-wash); color: var(--data-ink); }
.glyph .ico { width: 15px; height: 15px; flex: 0 0 15px; }
/* ---------- card ---------- */
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 18px 20px;
display: grid; gap: 14px; align-content: start; min-width: 0;
}
/* A card whose body runs edge to edge — a list or a table. Everything else in it brings
the padding back, or the heading sits against the border. */
.card.flush { padding: 0; }
.card.flush > .card-head { padding: 18px 20px 0; }
.card.flush > .card-foot { padding: 14px 20px 18px; }
.card-head { display: grid; gap: 4px; }
.card-head.split {
grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px;
}
.card-title {
margin: 0; font-size: 15px; font-weight: 650; letter-spacing: -.01em;
display: flex; align-items: center; gap: 9px; min-width: 0;
}
.card-note { margin: 0; color: var(--muted); font-size: 13px; max-width: 74ch; }
.card-foot {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding-top: 14px; border-top: 1px solid var(--line-soft);
}
.card-sub {
margin: 0; color: var(--quiet);
font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
}
.grid { display: grid; gap: 16px; }
.grid.two { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.grid.wide { grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr); }
/* ---------- tiles ---------- */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 1px;
background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.tile { background: var(--surface); padding: 14px 16px; display: grid; gap: 3px; }
.tile b { font-size: 22px; font-weight: 600; letter-spacing: -.02em; line-height: 1.2; }
.tile b.small { font-size: 14px; font-weight: 550; }
.tile span { color: var(--muted); font-size: 12px; }
/* The glyph is the tile's own row, above the number rather than beside it: a strip of
tiles is read down the numbers, and an icon in that column would push them out of line
the moment one tile had no icon to show. */
.tile .glyph { margin-bottom: 5px; }
/* Inside a card the surrounding border is already drawn, so the strip loses its own. */
.tiles.plain { border: 0; background: none; gap: 18px; border-radius: 0; overflow: visible; }
.tiles.plain .tile { padding: 0; background: none; }
.tiles.plain .tile b { font-size: 19px; }
/* Label-and-value rows: the quiet way to state settled facts inside a padded card. */
.kv { display: grid; }
.kv-row {
display: flex; align-items: center; justify-content: space-between; gap: 14px;
padding: 9px 0; border-bottom: 1px solid var(--line-soft); font-size: 13.5px;
}
.kv-row:first-child { padding-top: 0; }
.kv-row:last-child { padding-bottom: 0; border-bottom: 0; }
.kv-row > span:first-child { color: var(--muted); }
.kv-row > :last-child { text-align: right; }
/* ---------- controls ---------- */
button {
background: var(--surface-lift); color: var(--text);
border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 8px 13px; font: inherit; font-size: 13.5px; font-weight: 550; cursor: pointer;
}
button:hover:not(:disabled) { border-color: #3a444d; background: var(--surface-hi); }
button.primary { background: var(--accent); border-color: var(--accent); color: #06240a; font-weight: 650; }
button.primary:hover:not(:disabled) { background: #5cc554; border-color: #5cc554; }
button.danger { background: var(--danger-wash); border-color: rgba(229, 83, 75, .35); color: var(--danger-ink); }
button.danger:hover:not(:disabled) { background: rgba(229, 83, 75, .2); }
button.quiet { background: none; border-color: transparent; color: var(--muted); padding: 6px 8px; }
button.quiet:hover:not(:disabled) { background: var(--surface-lift); color: var(--text); }
button.small { padding: 5px 10px; font-size: 12.5px; }
button:disabled { opacity: .4; cursor: not-allowed; }
input[type=text], input[type=password], input[type=number], input[type=search],
input[type=datetime-local], select, textarea {
background: var(--bg); color: var(--text);
border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 8px 11px; font: inherit; font-size: 13.5px; min-width: 0;
}
textarea {
min-height: 62px; resize: vertical;
font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace;
}
input::placeholder, textarea::placeholder { color: var(--quiet); }
select { padding-right: 8px; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.row.tight { gap: 7px; }
.row.end { justify-content: flex-end; }
/* A row mixing labelled fields with bare buttons: the controls line up, not their tops. */
.row.bottom { align-items: flex-end; }
.field { display: grid; gap: 6px; min-width: 0; }
.field > span { font-size: 13px; font-weight: 550; }
.field > em { font-style: normal; color: var(--muted); font-size: 12px; }
.field input, .field select, .field textarea { width: 100%; }
.field.grow { flex: 1 1 260px; }
.field.narrow { max-width: 200px; flex: 0 0 auto; }
.fields { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.fields.stack { grid-template-columns: 1fr; }
.check { display: flex; gap: 9px; align-items: flex-start; font-size: 13.5px; cursor: pointer; }
.check input { margin: 3px 0 0; accent-color: var(--accent); flex: 0 0 auto; }
.check em { display: block; font-style: normal; color: var(--muted); font-size: 12px; }
.checks { display: grid; gap: 8px; }
.checks.columns { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
.hint { color: var(--muted); font-size: 12.5px; margin: 0; }
/* ---------- tags, notices ---------- */
.tag {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 9px; border-radius: 999px;
font-size: 11.5px; font-weight: 600; letter-spacing: .01em;
background: var(--surface-lift); color: var(--muted);
}
.tag[data-tone=ok] { background: var(--accent-wash); color: var(--accent-ink); }
.tag[data-tone=bad] { background: var(--danger-wash); color: var(--danger-ink); }
.tag[data-tone=warn] { background: var(--warn-wash); color: var(--warn-ink); }
.tag[data-tone=info] { background: var(--info-wash); color: var(--info-ink); }
.tag[data-tone=note] { background: var(--note-wash); color: var(--note-ink); }
.tag[data-tone=data] { background: var(--data-wash); color: var(--data-ink); }
.tag[data-tone=idle] { background: var(--surface-lift); color: var(--quiet); }
/* A verdict tag carries a dot in its own colour. Tone alone is a poor signal on a bad
monitor and no signal at all to somebody who cannot separate the green from the amber;
the dot is the shape that says these three are the same kind of statement. */
.tag[data-tone=ok]::before, .tag[data-tone=bad]::before,
.tag[data-tone=warn]::before, .tag[data-tone=idle]::before {
content: ""; width: 6px; height: 6px; border-radius: 50%;
background: currentColor; flex: 0 0 6px;
}
.tag[data-tone=idle]::before { opacity: .55; }
.tag .ico { width: 13px; height: 13px; flex: 0 0 13px; }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 8px; background: var(--quiet); }
.dot[data-tone=ok] { background: var(--accent); }
.dot[data-tone=bad] { background: var(--danger); }
.dot[data-tone=warn] { background: var(--warn-ink); }
.dot[data-tone=info] { background: var(--info); }
/* A notice is bordered on its leading edge in its own tone rather than all the way round:
the strip is what the eye finds down a page of cards, and it survives being one of
several stacked. */
.notice {
padding: 11px 14px; border-radius: var(--radius-sm);
border: 1px solid var(--line); border-left: 3px solid var(--quiet);
background: var(--surface-lift);
color: var(--muted); font-size: 13px;
}
.notice[data-tone=info] { border-left-color: var(--info); background: var(--info-wash); color: var(--info-ink); }
.notice[data-tone=warn] { border-left-color: var(--warn); background: var(--warn-wash); color: var(--warn-ink); }
.notice[data-tone=ok] { border-left-color: var(--accent); background: var(--accent-wash); color: var(--accent-ink); }
.notice[role=alert] {
border-color: rgba(229, 83, 75, .35); border-left-color: var(--danger);
background: var(--danger-wash); color: var(--danger-ink);
}
.empty { color: var(--quiet); font-size: 13px; margin: 0; }
/* A flush card has no padding of its own, so its empty state has to bring some. */
.card.flush > .empty, .card.flush .list > .empty { padding: 18px 20px; }
.score {
font: 650 19px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
color: var(--accent-ink);
}
code, .code {
font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace;
color: var(--muted);
}
/* ---------- table ---------- */
.table-wrap { overflow-x: auto; margin: 0 -20px -18px; padding: 0 20px 18px; }
.card.flush .table-wrap { margin: 0; padding: 0; }
table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
thead th {
color: var(--quiet); font-size: 11px; font-weight: 600;
letter-spacing: .07em; text-transform: uppercase;
background: var(--surface-lift); border-bottom-color: var(--line);
}
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover td { background: var(--surface-lift); }
/* The row under the pointer is marked at its leading edge as well as tinted — a long table
read across a wide screen loses a tint of this weight by the time the eye reaches the
far column. */
tbody tr:hover td:first-child { box-shadow: inset 2px 0 0 var(--accent); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
td.muted { color: var(--muted); white-space: normal; }
/* ---------- list ---------- */
.list { display: grid; }
.list-row {
display: grid; grid-template-columns: minmax(0, 1fr) auto;
gap: 14px; align-items: center;
padding: 13px 20px; border-bottom: 1px solid var(--line-soft);
}
.list-row:last-child { border-bottom: 0; }
.list-row:hover { background: var(--surface-lift); box-shadow: inset 2px 0 0 var(--accent); }
.list-row:focus-within { background: var(--surface-lift); }
a.list-row { color: inherit; text-decoration: none; }
.list-main { display: flex; align-items: center; gap: 12px; min-width: 0; }
.list-main > span { min-width: 0; }
.list-title { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.list-meta { display: block; color: var(--muted); font-size: 12.5px; margin-top: 2px; }
.list-actions { display: flex; gap: 7px; flex-wrap: wrap; justify-content: flex-end; }
/* The build history under a device row. Held to smaller type than an ordinary chip on
purpose: a set that has been through six releases must not end up outweighing its own
name in the row it belongs to. */
.list-versions { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 6px; }
.list-versions .chip { padding: 1px 7px; font-size: 11.5px; }
/* A person is violet, not green. Green is the console's verdict colour and an avatar is no
verdict — a list of viewers in accent read as a list of things that were working. */
.avatar {
display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 34px;
border-radius: 50%; background: var(--note-wash); color: var(--note-ink);
font-size: 13px; font-weight: 700;
}
.avatar[data-tone=ok] { background: var(--accent-wash); color: var(--accent-ink); }
.avatar[data-tone=idle] { background: var(--surface-hi); color: var(--quiet); }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 8px; border-radius: 6px;
background: var(--surface-lift); color: var(--muted); font-size: 12px;
}
.chip[data-tone=accent] { color: var(--accent-ink); background: var(--accent-wash); }
.chip[data-tone=warn] { color: var(--warn-ink); background: var(--warn-wash); }
.chip[data-tone=bad] { color: var(--danger-ink); background: var(--danger-wash); }
.chip[data-tone=info] { color: var(--info-ink); background: var(--info-wash); }
.chip[data-tone=note] { color: var(--note-ink); background: var(--note-wash); }
.chip[data-tone=data] { color: var(--data-ink); background: var(--data-wash); }
.chip .ico { width: 12px; height: 12px; flex: 0 0 12px; }
.chip.mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11.5px; }
details summary { color: var(--muted); cursor: pointer; font-size: 13px; }
details[open] summary { margin-bottom: 8px; }
/* ---------- log viewer ---------- */
.log {
height: min(62vh, 760px); min-height: 340px; overflow: auto;
background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius-sm);
font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace;
}
.log-line {
display: grid; grid-template-columns: 168px 56px minmax(200px, 260px) minmax(280px, 1fr);
gap: 10px; padding: 4px 11px; border-bottom: 1px solid var(--line-soft);
}
.log-line:hover { background: var(--surface); }
.log-time, .log-attrs { color: var(--quiet); }
.log-level { font-weight: 700; color: var(--info-ink); }
.log-level.ERROR { color: var(--danger-ink); }
.log-level.WARN { color: var(--warn-ink); }
.log-level.DEBUG { color: var(--quiet); }
/* The line is banded in the level's colour. Scrolling a log is looking for the one line
that is not INFO, and a coloured word four columns wide is easy to scroll past. */
.log-line.ERROR { background: rgba(229, 83, 75, .07); box-shadow: inset 2px 0 0 var(--danger); }
.log-line.WARN { background: rgba(239, 196, 107, .06); box-shadow: inset 2px 0 0 var(--warn); }
.log-empty { padding: 18px; color: var(--quiet); }
/* ---------- responsive ---------- */
@media (max-width: 1000px) {
:root { --rail: 62px; }
.rail { padding: 16px 7px 10px; }
.rail-brand { justify-content: center; padding: 0 0 14px; }
.rail-brand-copy, .rail-group, .rail-link span, .rail-foot-copy { display: none; }
.rail-link { justify-content: center; padding: 0; min-height: 38px; }
.rail-foot { justify-content: center; padding: 12px 0 0; }
.grid.wide { grid-template-columns: 1fr; }
.page { padding: 20px 14px 44px; }
.table-wrap { margin: 0 -20px -18px; }
}
@media (max-width: 620px) {
.card { padding: 16px; }
.list-row { grid-template-columns: 1fr; }
.list-actions { justify-content: flex-start; }
.log-line { grid-template-columns: 130px 50px 1fr; }
.log-attrs { grid-column: 1 / -1; }
}
+289
View File
@@ -0,0 +1,289 @@
/* The console's shared runtime: one HTTP client, one error banner, one refresh loop and
the handful of formatters and markup helpers every page draws with. A page fragment
should contain the decisions that are its own and nothing else — if two pages need the
same piece of markup it belongs here, beside `ui`.
Pages plug into it rather than reaching around it:
Admin.onStatus(fn) fn(status) on every /admin/api/status poll
Admin.onRefresh(fn) an async task run alongside that poll
Admin.ready(fn) once, after the page has parsed
Admin.act(fn) run a mutation, then refresh, reporting failure in the banner */
const Admin = (() => {
const page = document.querySelector('[data-admin-page]').dataset.adminPage;
const statusHandlers = [];
const refreshTasks = [];
/* ---- transport ------------------------------------------------------- */
// The sign-in behind this page lasts thirty minutes and slides forward only for requests
// an operator actually caused, so the poll of a tab nobody is reading cannot keep it
// alive. Anything the console does while somebody is working it says so with this
// header; see operatorPresent on the server.
const ACTIVITY_WINDOW_MS = 5 * 60 * 1000;
let lastInteraction = Date.now();
for (const name of ['pointerdown', 'pointermove', 'keydown', 'wheel', 'scroll']) {
window.addEventListener(name, () => { lastInteraction = Date.now(); }, { passive: true });
}
// A 401 is an expired sign-in rather than a wrong token. Reloading re-renders this URL as
// the login form with `next` pointing back at it, so the operator signs in once and lands
// where they were, instead of reading a banner the page can never clear. The timestamp is
// what stops a 401 that survives the reload from looping.
const RELOGIN_KEY = 'memby-admin-relogin';
function reauthenticate() {
try {
if (Date.now() - Number(sessionStorage.getItem(RELOGIN_KEY) || 0) < 30000) return false;
sessionStorage.setItem(RELOGIN_KEY, String(Date.now()));
} catch (err) {
// Private-mode storage refusals must not cost the reload; take the loop risk.
}
window.location.reload();
return true;
}
async function api(path, options = {}) {
const active = Date.now() - lastInteraction < ACTIVITY_WINDOW_MS;
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(active ? { 'X-Memby-Admin-Active': '1' } : {}),
...(options.headers || {}),
},
});
if (response.status === 401) {
throw new Error(reauthenticate()
? 'Your sign-in has expired. Signing in again…'
: 'Your sign-in has expired. Reload this page to sign in again.');
}
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || ('Request failed (' + response.status + ')'));
}
return response.status === 204 ? null : response.json();
}
/* ---- formatting ------------------------------------------------------ */
const escape = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[char]));
const number = (value) => (value ?? 0).toLocaleString();
const when = (value) => (value ? new Date(value).toLocaleString() : '—');
const time = (value) => (value ? new Date(value).toLocaleTimeString() : '—');
function duration(ms) {
if (!ms) return '0s';
const seconds = Math.round(ms / 1000);
if (seconds < 60) return seconds + 's';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ' + (seconds % 60) + 's';
return Math.floor(minutes / 60) + 'h ' + (minutes % 60) + 'm';
}
function bytes(value) {
const units = ['B', 'KB', 'MB', 'GB'];
let amount = Number(value || 0);
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; }
return (unit === 0 ? amount : amount.toFixed(1)) + ' ' + units[unit];
}
const initials = (name) => String(name || '?').trim().split(/\s+/).slice(0, 2)
.map((part) => part[0] || '').join('').toUpperCase();
// "Seen in the last quarter of an hour" is what the console means by active: a television
// checks in every few seconds while somebody is using it.
const ACTIVE_MS = 15 * 60 * 1000;
const IDLE_MS = 7 * 24 * 60 * 60 * 1000;
const recent = (value) => Boolean(value) && Date.now() - new Date(value).getTime() < ACTIVE_MS;
// Three states rather than two, because "not active this minute" covers both a set
// somebody switched off after breakfast and one that has not been seen since a firmware
// update in March — and only the second is worth an operator's attention. Green is on
// now, amber is a set in ordinary use that happens to be off, red is one that has
// stopped checking in. A device with no timestamp at all is red: never seen is the
// strongest version of not seen.
function presence(value) {
const seen = value ? new Date(value).getTime() : 0;
if (!seen) return { tone: 'bad', label: 'never seen' };
const age = Date.now() - seen;
if (age < ACTIVE_MS) return { tone: 'ok', label: 'active now' };
if (age < IDLE_MS) return { tone: 'warn', label: 'seen recently' };
return { tone: 'bad', label: 'not seen lately' };
}
const fmt = { escape, number, when, time, duration, bytes, initials, recent, presence };
/* ---- markup components ----------------------------------------------- */
/* Icons live here and nowhere else. Each is the `d` of one stroked path on a 24×24 grid,
the same shape the rail's marks take, so a page never carries SVG markup of its own and
two screens showing the same idea cannot draw it two ways. A fragment asks for one by
writing data-icon="…" on any element; a script asks with ui.icon(). An unknown name
draws nothing rather than a broken box — a mark is decoration, and a typo in one must
never be what an operator notices about a page. */
const icons = {
library: 'M3 5h18v14H3zM7 5v14M17 5v14M3 9.5h4M3 14.5h4M17 9.5h4M17 14.5h4',
people: 'M15 19v-1.2a3.3 3.3 0 0 0-3.3-3.3H6.8A3.3 3.3 0 0 0 3.5 17.8V19M9.2 11a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4ZM17 10.6a3 3 0 0 0-1.4-5.7M20.5 19v-1.2a3.3 3.3 0 0 0-2.4-3.2',
person: 'M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20M12 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z',
tv: 'M4 5h16v10H4zM9 19h6M12 15v4M8 2.5 12 5l4-2.5',
sliders: 'M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6',
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7.2V12l3 1.8',
pulse: 'M3 12h3.5L9 19l5-14 2.5 7H21',
chart: 'M4 19V9m5 10V5m5 14v-7m5 7V3',
chip: 'M8 8h8v8H8zM4.5 4.5h15v15h-15zM9 2v2.5M15 2v2.5M9 19.5V22M15 19.5V22M2 9h2.5M2 15h2.5M19.5 9H22M19.5 15H22',
database: 'M12 8.2c4.4 0 8-1.2 8-2.6S16.4 3 12 3 4 4.2 4 5.6s3.6 2.6 8 2.6ZM4 5.6v12.8C4 19.8 7.6 21 12 21s8-1.2 8-2.6V5.6M4 12c0 1.4 3.6 2.6 8 2.6s8-1.2 8-2.6',
download: 'M12 3.5v10m0 0 4-4m-4 4-4-4M4.5 18h15',
sync: 'M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5',
search: 'M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20',
star: 'm12 3.2 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.4l5.9-.8L12 3.2Z',
sparkle: 'm10 3 1.5 4.3L16 8.8l-4.5 1.5L10 14.6 8.5 10.3 4 8.8l4.5-1.5L10 3ZM17.5 14l.9 2.4 2.6.9-2.6.9-.9 2.4-.9-2.4-2.6-.9 2.6-.9.9-2.4Z',
bell: 'M6.2 9.5a5.8 5.8 0 1 1 11.6 0c0 4.6 2.2 5.9 2.2 5.9H4s2.2-1.3 2.2-5.9M10 19.5a2 2 0 0 0 4 0',
shield: 'm12 3 7.5 3v5.4c0 5-3.2 8.2-7.5 9.6-4.3-1.4-7.5-4.6-7.5-9.6V6L12 3Zm-2.6 8.7 1.9 1.9 3.6-3.6',
wrench: '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',
play: 'M8 5.2v13.6L19 12 8 5.2ZM4 5v14',
list: 'M4 7h16M4 12h16M4 17h10',
inbox: 'M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5',
history: 'M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8',
check: 'm5 12.5 4.5 4.5L19 7.5',
alert: 'M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z',
power: 'M12 3v9M7.5 6.2a7.5 7.5 0 1 0 9 0',
key: 'M14.5 3a6.5 6.5 0 1 0 3.4 12L19 14h2v-2h2V9.5l-2.5-2.5A6.5 6.5 0 0 0 14.5 3Zm-2.6 4.6a1.6 1.6 0 1 1-2.3 2.3 1.6 1.6 0 0 1 2.3-2.3Z',
};
const icon = (name) => (icons[name]
? '<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="' + icons[name] + '"/></svg>'
: '');
// An icon in a tinted plate — the tile and card-heading mark. The tone is what says which
// area a thing belongs to, so it is passed rather than derived.
const glyph = (name, tone) => (icons[name]
? '<span class="glyph"' + (tone ? ' data-tone="' + tone + '"' : '') + '>' + icon(name) + '</span>'
: '');
const ui = {
icon,
glyph,
// [label, value, options?] → the tile strip used at the top of most pages. The options
// are { small, icon, tone }: `small` for a value that is a sentence rather than a
// number, the other two for the mark above it.
tiles: (entries) => entries.map(([label, value, options]) => {
const opts = typeof options === 'object' && options !== null ? options : { small: options };
return '<div class="tile">' + (opts.icon ? glyph(opts.icon, opts.tone) : '') +
'<b' + (opts.small ? ' class="small"' : '') + '>' + escape(String(value)) +
'</b><span>' + escape(label) + '</span></div>';
}).join(''),
tag: (label, tone) => '<span class="tag"' + (tone ? ' data-tone="' + tone + '"' : '') +
'>' + escape(label) + '</span>',
chip: (label, tone) => '<span class="chip"' + (tone ? ' data-tone="' + tone + '"' : '') +
'>' + escape(label) + '</span>',
empty: (message) => '<p class="empty">' + escape(message) + '</p>',
emptyRow: (columns, message) => '<tr><td colspan="' + columns + '" class="muted">' +
escape(message) + '</td></tr>',
};
/* ---- page plumbing --------------------------------------------------- */
const $ = (id) => document.getElementById(id);
// `data-icon` (with an optional `data-icon-tone`) on any element is how a fragment asks
// for a mark without writing SVG. It is applied once, when the page has parsed, because
// what carries it is the static markup a fragment ships — anything a poll redraws asks
// with ui.glyph instead, or the mark would be wiped on the first refresh. The attribute
// is consumed, so running this again over the same tree cannot double the icon.
function decorate(root = document) {
for (const element of root.querySelectorAll('[data-icon]')) {
element.insertAdjacentHTML('afterbegin', glyph(element.dataset.icon, element.dataset.iconTone));
delete element.dataset.icon;
}
}
function error(message) {
const banner = $('error');
banner.textContent = message || '';
banner.hidden = !message;
}
// Never redraw markup the operator is working inside. Every poll would otherwise take a
// half-typed field, an open select or a scrolled list away mid-edit.
const settled = (element) => element && !element.contains(document.activeElement);
// The same rule for a single control: fill it in unless it is the one being used.
function fill(element, value) {
if (element && document.activeElement !== element) element.value = value;
return element;
}
function check(element, value) {
if (element && document.activeElement !== element) element.checked = Boolean(value);
return element;
}
const onStatus = (fn) => statusHandlers.push(fn);
const onRefresh = (fn) => refreshTasks.push(fn);
const ready = (fn) => (document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', fn) : fn());
function live(ok, label) {
const tag = $('live');
tag.textContent = label;
tag.dataset.tone = ok ? 'ok' : 'bad';
$('rail-live').dataset.tone = ok ? 'ok' : 'bad';
$('rail-live-label').textContent = ok ? 'online' : 'unreachable';
}
async function refresh() {
try {
const status = await api('/admin/api/status');
$('rail-version').textContent = 'gateway ' + (status.serverVersion || 'unknown');
statusHandlers.forEach((handler) => handler(status));
await Promise.all(refreshTasks.map((task) => task()));
live(true, 'updated ' + new Date().toLocaleTimeString());
error('');
} catch (err) {
live(false, 'not responding');
error(err.message);
}
}
async function act(fn) {
try {
await fn();
await refresh();
} catch (err) {
error(err.message);
}
}
/* ---- refresh loop ----------------------------------------------------- */
// Someone leaves this open on a second monitor, which makes its poll the gateway's most
// frequent caller by a wide margin. It stops entirely on a hidden tab and catches up the
// moment the tab is looked at again — a background tab nobody is reading has no status
// worth fetching.
const REFRESH_MS = 30000;
let timer = null;
function schedule() {
clearInterval(timer);
timer = document.hidden ? null : setInterval(refresh, REFRESH_MS);
}
document.addEventListener('visibilitychange', () => {
schedule();
if (!document.hidden) refresh();
});
ready(() => { decorate(); refresh(); schedule(); });
return {
page, api, fmt, ui, $, error, settled, fill, check,
onStatus, onRefresh, ready, refresh, act, decorate,
};
})();
@@ -0,0 +1,58 @@
<a class="crumb" href="/admin/accounts">← All users</a>
<section class="card" id="account-identity">
<p class="empty">Loading this user…</p>
</section>
<div class="grid wide">
<section class="card flush">
<div class="card-head">
<h2 class="card-title" data-icon="tv" data-icon-tone="info">Devices</h2>
<p class="card-note">Every build a set has been seen running is listed under it.
Signing one out revokes its Memby session, drops that history and removes it from
Emby's own device list. Its Emby account is not changed.</p>
</div>
<div class="list" id="account-devices"></div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="sparkle" data-icon-tone="note">Recommendation setup</h2>
<p class="card-note">The prompt appears the next time this person opens Memby on any
of their televisions.</p>
</div>
<div id="account-recommendations"></div>
<div class="card-foot" id="account-recommendation-actions"></div>
</section>
</div>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="sliders" data-icon-tone="ok">Settings</h2>
<p class="card-note">These live on the server and follow the person, so a change here
reaches every television they use — usually within a few seconds, and on the next
launch for a set that is switched off.</p>
</div>
<span id="account-settings-state"></span>
</div>
<div class="fields" id="account-settings"></div>
<div class="card-foot">
<button class="primary" data-account-action="push-preferences">Push to their televisions</button>
<button data-account-action="reload-preferences">Discard changes</button>
<button data-account-action="reset-preferences">Restore defaults</button>
<a class="crumb" id="account-settings-history">History and rollback →</a>
<span class="hint" id="account-settings-message"></span>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="alert" data-icon-tone="bad">Remove Memby access</h2>
<p class="card-note">Signs every one of this person's Memby devices out. Their Emby
account, viewing history and library permissions are untouched.</p>
</div>
<div class="row">
<button class="danger" data-account-action="remove-account">Remove Memby access</button>
</div>
</section>
+284
View File
@@ -0,0 +1,284 @@
/* One person: their televisions, their recommendation setup and the settings that follow
them to every set. The identity is in the URL rather than in a query string so the page
can be linked, bookmarked and returned to after a sign-in. */
const { fmt, ui, $ } = Admin;
const userId = decodeURIComponent(location.pathname.split('/').filter(Boolean).pop() || '');
const base = '/admin/api/accounts/' + encodeURIComponent(userId);
// The accounts endpoint answers for the whole household in one read and carries the
// preference catalogue with it, so this page asks for that rather than adding a per-user
// endpoint that would return a slice of the same query.
let catalogue = [];
// True while the operator has edited the settings form without saving. The page polls every
// thirty seconds and a redraw would take a half-finished change away mid-sentence, so a
// dirty form keeps the DOM it already has until it is saved, discarded or reloaded.
let dirty = false;
/* ---- settings controls ------------------------------------------------- */
function settingControl(definition, value) {
const key = fmt.escape(definition.key);
const name = fmt.escape(definition.name);
const description = fmt.escape(definition.description);
if (definition.kind === 'toggle') {
return '<label class="check"><input type="checkbox" data-pref-key="' + key +
'" data-pref-kind="toggle"' + (value ? ' checked' : '') + '><span>' + name +
'<em>' + description + '</em></span></label>';
}
if (definition.kind === 'choice' || definition.kind === 'number') {
// A number's unit comes from the catalogue. Assuming minutes was safe while the only
// number was a time budget, and wrong the moment a second one counted anything else.
const unit = definition.unit || '';
const options = definition.kind === 'number'
? (definition.numbers || []).map((amount) =>
[String(amount), amount === 0 ? 'No limit' : (unit ? amount + ' ' + unit : String(amount))])
: (definition.options || []).map((option) => [option.value, option.label]);
return '<label class="field"><span>' + name + '</span><em>' + description + '</em>' +
'<select data-pref-key="' + key + '" data-pref-kind="' + fmt.escape(definition.kind) + '">' +
options.map(([optionValue, optionLabel]) => '<option value="' + fmt.escape(optionValue) + '"' +
(String(value) === optionValue ? ' selected' : '') + '>' +
fmt.escape(String(optionLabel)) + '</option>').join('') + '</select></label>';
}
if (definition.kind === 'multi') {
// Rendered in the viewer's own order, then whatever they have not selected. The order
// of these rows is the order the launcher draws them in, so preserving it matters as
// much as which are ticked.
const selected = Array.isArray(value) ? value : [];
const ordered = selected.concat((definition.options || [])
.map((option) => option.value).filter((option) => !selected.includes(option)));
return '<div class="field"><span>' + name + '</span><em>' + description + '</em>' +
'<div class="checks" data-pref-key="' + key + '" data-pref-kind="multi">' +
ordered.map((option) => {
const match = (definition.options || []).find((entry) => entry.value === option);
if (!match) return '';
return '<label class="check"><input type="checkbox" data-pref-option="' +
fmt.escape(option) + '"' + (selected.includes(option) ? ' checked' : '') + '><span>' +
fmt.escape(match.label) + '</span></label>';
}).join('') + '</div></div>';
}
// A free-form list of server row ids: one per line, which is also how the television
// stores them. There is no vocabulary to offer, because these ids ship from the gateway
// without an app release.
const entries = Array.isArray(value) ? value : [];
return '<label class="field"><span>' + name + '</span><em>' + description + '</em>' +
'<textarea data-pref-key="' + key + '" data-pref-kind="list" spellcheck="false" ' +
'placeholder="One row id per line">' + fmt.escape(entries.join('\n')) + '</textarea></label>';
}
// Reads the form back into the document shape the server normalises. Unknown keys and
// illegal values are the server's problem by design — this only has to be honest about what
// the operator selected.
function collectSettings() {
const preferences = {};
$('account-settings').querySelectorAll('[data-pref-key]').forEach((control) => {
const key = control.dataset.prefKey;
switch (control.dataset.prefKind) {
case 'toggle': preferences[key] = control.checked; break;
case 'number': preferences[key] = Number(control.value); break;
case 'multi':
preferences[key] = Array.from(control.querySelectorAll('input:checked'))
.map((input) => input.dataset.prefOption);
break;
case 'list':
preferences[key] = control.value.split('\n').map((entry) => entry.trim()).filter(Boolean);
break;
default: preferences[key] = control.value;
}
});
return preferences;
}
function renderSettings(account) {
const settings = account.settings || {};
const values = settings.preferences || {};
const areas = [];
catalogue.forEach((definition) => {
let area = areas.find((entry) => entry.name === definition.area);
if (!area) areas.push(area = { name: definition.area, definitions: [] });
area.definitions.push(definition);
});
$('account-settings-state').innerHTML = settings.saved
? ui.tag('r' + fmt.number(settings.revision || 0) + ' · ' + (settings.source || 'device') +
' · ' + fmt.when(settings.updatedAt), settings.source === 'admin' ? 'warn' : 'ok')
: ui.tag('defaults · never synced', 'idle');
$('account-settings').innerHTML = areas.map((area) =>
'<div class="field"><p class="card-sub">' + fmt.escape(area.name) + '</p>' +
'<div class="checks">' + area.definitions.map((definition) =>
settingControl(definition, values[definition.key])).join('') + '</div></div>').join('');
}
/* ---- the rest of the page ---------------------------------------------- */
// Every build this set has been seen running, newest first and the current one flagged.
// One television that has been through four releases is a different thing from four
// televisions, and the history is what says which of those an operator is looking at.
function versionHistory(device) {
const versions = device.versions || [];
if (!versions.length) return '';
return '<span class="list-versions">' + versions.map((entry) =>
ui.chip(entry.version + (entry.version === device.version ? ' · now' : ''),
entry.version === device.version ? 'accent' : null)).join('') + '</span>';
}
function renderDevices(account) {
const devices = account.devices || [];
$('account-devices').innerHTML = devices.length ? devices.map((device) => {
const seen = fmt.presence(device.lastSeen);
return '<div class="list-row"><span class="list-main"><span>' +
'<span class="list-title">' +
'<span class="dot" data-tone="' + seen.tone + '" title="' + seen.label + '"></span>' +
fmt.escape(device.name || 'Memby TV') + '</span>' +
'<span class="list-meta">' +
fmt.escape(device.version ? 'Memby ' + device.version : 'Legacy Memby client') +
' · ' + fmt.escape(seen.label) +
' · last seen ' + fmt.escape(fmt.when(device.lastSeen)) +
' · signed in ' + fmt.escape(fmt.when(device.signedInAt)) +
'</span>' + versionHistory(device) + '</span></span>' +
'<span class="list-actions">' +
'<button class="small" data-account-action="rename-device" data-device-id="' +
fmt.escape(device.id) + '" data-device-name="' + fmt.escape(device.name) + '"' +
(device.id ? '' : ' disabled') + '>Rename</button>' +
'<button class="small danger" data-account-action="remove-device" data-device-id="' +
fmt.escape(device.id) + '"' + (device.id ? '' : ' disabled') + '>Sign out</button>' +
'</span></div>';
}).join('') : ui.empty('No devices are signed in to this user.');
}
function renderRecommendations(account) {
const prompt = account.recommendations || {};
const ratings = prompt.ratings || [];
const dimensions = [
['Genres', prompt.genres], ['Studios', prompt.studios], ['Actors', prompt.actors],
['Actresses', prompt.actresses], ['Directors', prompt.directors],
['Types', prompt.contentTypes],
].filter((entry) => (entry[1] || []).length);
const chips = ratings.map((rating) =>
ui.chip(rating.title + ' · ' + fmt.number(rating.rating) + ' ★', 'warn')).join('') +
dimensions.flatMap(([label, values]) => (values || [])
.map((value) => ui.chip(label + ': ' + value))).join('');
const state = prompt.completed
? ui.tag('completed', 'ok')
: prompt.prompted ? ui.tag('prompt queued', 'warn') : ui.tag('not invited', 'idle');
$('account-recommendations').innerHTML = '<div class="row tight">' + state + '</div>' +
(chips ? '<div class="chips">' + chips + '</div>'
: ui.empty('No recommendation selections have been saved.'));
$('account-recommendation-actions').innerHTML = prompt.completed
? '<button data-account-action="reset-recommendations">Clear stored choices</button>'
: prompt.prompted
? '<button data-account-action="cancel-recommendation-prompt">Cancel prompt</button>'
: '<button class="primary" data-account-action="prompt-recommendations">Send setup prompt</button>';
}
function renderIdentity(account) {
const devices = account.devices || [];
const active = devices.filter((device) => fmt.recent(device.lastSeen)).length;
$('page-title').textContent = account.username || 'Unnamed user';
$('page-intro').textContent = 'Memby user · ' + fmt.number(devices.length) + ' device' +
(devices.length === 1 ? '' : 's') + ' · last seen ' + fmt.when(account.lastSeen);
document.title = (account.username || 'User') + ' · Memby admin';
$('account-identity').innerHTML = '<div class="row">' +
'<span class="avatar">' + fmt.escape(fmt.initials(account.username)) + '</span>' +
'<span class="list-title">' + fmt.escape(account.username || 'Unnamed user') + '</span>' +
(active ? ui.tag(active + ' active now', 'ok') : ui.tag('idle', 'idle')) +
'<span class="chip mono">' + fmt.escape(account.id) + '</span></div>';
}
Admin.onRefresh(async () => {
$('account-settings-history').href = '/admin/accounts/' + encodeURIComponent(userId) + '/settings';
const payload = await Admin.api('/admin/api/accounts');
catalogue = payload.catalogue || catalogue;
const account = (payload.accounts || []).find((entry) => entry.id === userId);
if (!account) {
$('account-identity').innerHTML =
ui.empty('This user is no longer signed in to Memby.');
return;
}
renderIdentity(account);
renderDevices(account);
renderRecommendations(account);
if (!dirty) renderSettings(account);
});
/* ---- actions ------------------------------------------------------------ */
function message(text) { $('account-settings-message').textContent = text || ''; }
document.addEventListener('input', (event) => {
if (!$('account-settings').contains(event.target)) return;
dirty = true;
message('unsaved changes');
});
document.addEventListener('click', (event) => {
const button = event.target.closest('[data-account-action]');
if (!button) return;
const action = button.dataset.accountAction;
if (action === 'rename-device') {
const name = prompt('Name this Memby device', button.dataset.deviceName || 'Memby TV');
if (name === null || !name.trim()) return;
Admin.act(() => Admin.api(base + '/devices/' + encodeURIComponent(button.dataset.deviceId), {
method: 'PUT', body: JSON.stringify({ deviceName: name.trim() }),
}));
}
if (action === 'remove-device') {
if (!confirm('Sign this device out of Memby? Its Emby account will not be changed.')) return;
Admin.act(() => Admin.api(base + '/devices/' +
encodeURIComponent(button.dataset.deviceId), { method: 'DELETE' }));
}
if (action === 'remove-account') {
if (!confirm('Remove Memby access for ' + ($('page-title').textContent || 'this user') +
'? Every Memby device will be signed out. Their Emby account will not be changed.')) return;
Admin.act(async () => {
await Admin.api(base + '/sessions', { method: 'DELETE' });
location.href = '/admin/accounts';
});
}
if (action === 'reset-recommendations') {
if (!confirm('Clear this persons stored recommendation choices? Viewing history remains intact.')) return;
Admin.act(() => Admin.api(base + '/recommendations', { method: 'DELETE' }));
}
if (action === 'prompt-recommendations') {
Admin.act(() => Admin.api(base + '/recommendations/prompt', { method: 'PUT' }));
}
if (action === 'cancel-recommendation-prompt') {
if (!confirm('Cancel this persons queued recommendation prompt?')) return;
Admin.act(() => Admin.api(base + '/recommendations', { method: 'DELETE' }));
}
if (action === 'push-preferences') {
const preferences = collectSettings();
message('pushing…');
Admin.act(async () => {
await Admin.api(base + '/preferences', {
method: 'PUT', body: JSON.stringify({ preferences }),
});
// Cleared before the refresh, so the form is redrawn from what the server actually
// stored rather than from what was submitted.
dirty = false;
message('pushed');
});
}
if (action === 'reload-preferences') {
dirty = false;
message('');
Admin.refresh();
}
if (action === 'reset-preferences') {
if (!confirm('Restore the Memby defaults for this person? Their televisions will pick ' +
'the change up the next time they check in.')) return;
dirty = false;
Admin.act(() => Admin.api(base + '/preferences', { method: 'DELETE' }));
}
});
@@ -0,0 +1,13 @@
<p class="notice" data-tone="info">
This is the Memby user list, not the Emby user directory. A person appears here only
after signing in to the Memby app. Removing access signs their Memby devices out and does
not delete or change their Emby account.
</p>
<div class="tiles" id="account-tiles"></div>
<section class="card flush">
<div class="list" id="account-list">
<p class="empty">Loading users…</p>
</div>
</section>
@@ -0,0 +1,52 @@
/* A directory, and only a directory. Everything you can *do* to a person lives on their own
page: this list used to render a full seventeen-control settings editor for every account
at once, which meant the page grew with the household and an operator scrolled past four
other people's preferences to reach the one they came for. */
const { fmt, ui, $ } = Admin;
function accountRow(account) {
const devices = account.devices || [];
const active = devices.filter((device) => fmt.recent(device.lastSeen)).length;
const prompt = account.recommendations || {};
const state = prompt.completed
? ui.tag('personalised', 'ok')
: prompt.prompted ? ui.tag('prompt queued', 'warn') : ui.tag('not invited', 'idle');
const seen = fmt.presence(account.lastSeen);
return '<a class="list-row" href="/admin/accounts/' + encodeURIComponent(account.id) + '">' +
'<span class="list-main">' +
'<span class="avatar">' + fmt.escape(fmt.initials(account.username)) + '</span>' +
'<span>' +
'<span class="list-title">' + fmt.escape(account.username || 'Unnamed user') +
'<span class="dot" data-tone="' + seen.tone + '" title="' + seen.label + '"></span>' +
'</span>' +
'<span class="list-meta">' + fmt.number(devices.length) + ' device' +
(devices.length === 1 ? '' : 's') +
(active ? ' · ' + active + ' active now' : '') +
' · last seen ' + fmt.escape(fmt.when(account.lastSeen)) + '</span>' +
'</span>' +
'</span>' +
'<span class="list-actions">' + state + '<span class="crumb">Manage</span></span></a>';
}
Admin.onRefresh(async () => {
const payload = await Admin.api('/admin/api/accounts');
const accounts = payload.accounts || [];
const devices = accounts.flatMap((account) => account.devices || []);
const prompts = accounts.filter((account) => account.recommendations?.completed).length;
const queued = accounts.filter((account) =>
account.recommendations?.prompted && !account.recommendations?.completed).length;
$('account-tiles').innerHTML = ui.tiles([
['Memby users', fmt.number(accounts.length), { icon: 'people', tone: 'note' }],
['signed-in devices', fmt.number(devices.length), { icon: 'tv', tone: 'info' }],
['active in the last quarter hour', fmt.number(devices.filter((device) =>
fmt.recent(device.lastSeen)).length), { icon: 'pulse', tone: 'ok' }],
['recommendation setups completed', fmt.number(prompts), { icon: 'check', tone: 'ok' }],
['setup prompts queued', fmt.number(queued), { icon: 'sparkle', tone: 'note' }],
]);
$('account-list').innerHTML = accounts.length
? accounts.map(accountRow).join('')
: ui.empty('No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.');
});
@@ -0,0 +1,20 @@
<div class="tiles" id="client-tiles"></div>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="tv" data-icon-tone="info">Devices</h2>
<p class="card-note">Every request carries what that build understands. A feature is
only presented to a device that declares its contract, which is what lets an older
set keep working while a new one gets the new behaviour. Status is whether the set is
reporting that list at all; a build old enough to say nothing is served the fallback.</p>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Device</th><th>Person</th><th>App</th>
<th>Builds seen</th><th>Status</th><th>Last seen</th>
</tr></thead>
<tbody id="client-rows"></tbody>
</table>
</div>
</section>
@@ -0,0 +1,41 @@
const { fmt, ui, $ } = Admin;
// Every build this set has been seen running, newest first, with the one it is on now in
// accent. The current version is already its own column; this is the column that says
// whether a row is one television with a history or one of several rows a single set left
// behind, which is the question duplicates used to make unanswerable.
function versionHistory(client) {
const versions = client.versions || [];
if (!versions.length) return '<span class="muted">—</span>';
return '<span class="list-versions">' + versions.map((entry) =>
ui.chip(entry.version, entry.version === client.version ? 'accent' : null)).join('') +
'</span>';
}
Admin.onStatus((status) => {
const clients = status.clients || [];
const capable = clients.filter((client) =>
(client.capabilities || []).includes('server_features_v1'));
const versions = new Set(clients.map((client) => client.version).filter(Boolean));
$('client-tiles').innerHTML = ui.tiles([
['devices known', fmt.number(clients.length), { icon: 'tv', tone: 'info' }],
['active in the last quarter hour', fmt.number(clients.filter((client) =>
fmt.recent(client.lastSeen)).length), { icon: 'pulse', tone: 'ok' }],
['reporting their capabilities', fmt.number(capable.length), { icon: 'sliders', tone: 'ok' }],
['app builds in service', fmt.number(versions.size), { icon: 'download', tone: 'note' }],
]);
$('client-rows').innerHTML = clients.length ? clients.map((client) => {
const declares = (client.capabilities || []).includes('server_features_v1');
const seen = fmt.presence(client.lastSeen);
return '<tr><td><span class="row tight">' +
'<span class="dot" data-tone="' + seen.tone + '" title="' + seen.label + '"></span>' +
fmt.escape(client.deviceName || 'Memby TV') + '</span></td>' +
'<td>' + fmt.escape(client.username) + '</td>' +
'<td>' + fmt.escape(client.version || 'legacy') + '</td>' +
'<td>' + versionHistory(client) + '</td>' +
'<td>' + ui.tag(declares ? 'reported' : 'missing', declares ? 'ok' : 'warn') + '</td>' +
'<td>' + fmt.escape(fmt.when(client.lastSeen)) + '</td></tr>';
}).join('') : ui.emptyRow(6, 'No devices have signed in yet.');
});
@@ -0,0 +1,26 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="chart" data-icon-tone="info">Launcher rows</h2>
<p class="card-note">Impressions are rows drawn, focuses are rows the D-pad reached,
and dwell is how long it stayed there. Open rate is what a row was worth.</p>
</div>
<label class="field narrow"><span>Window</span>
<select id="engagement-days">
<option value="1">24 hours</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
</select></label>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Row</th><th>Kind</th>
<th class="num">Dwell</th><th class="num">Impressions</th>
<th class="num">Focuses</th><th class="num">Opened</th>
<th class="num">Open rate</th><th class="num">Viewers</th>
</tr></thead>
<tbody id="engagement-rows"></tbody>
</table>
</div>
</section>
@@ -0,0 +1,18 @@
const { fmt, ui, $ } = Admin;
Admin.onRefresh(async () => {
const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value);
const rows = payload.rows || [];
$('engagement-rows').innerHTML = rows.length ? rows.map((row) =>
'<tr><td>' + fmt.escape(row.rowId) + '</td>' +
'<td class="muted">' + fmt.escape(row.rowKind || '—') + '</td>' +
'<td class="num">' + fmt.duration(row.dwellMs) + '</td>' +
'<td class="num">' + fmt.number(row.impressions) + '</td>' +
'<td class="num">' + fmt.number(row.focuses) + '</td>' +
'<td class="num">' + fmt.number(row.selects) + '</td>' +
'<td class="num">' + Math.round((row.selectRate || 0) * 100) + '%</td>' +
'<td class="num">' + fmt.number(row.viewers) + '</td></tr>').join('')
: ui.emptyRow(8, 'No events in this window.');
});
Admin.ready(() => $('engagement-days').addEventListener('change', Admin.refresh));
@@ -0,0 +1,23 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="sliders" data-icon-tone="ok">Control plane</h2>
<p class="card-note">Every optional feature has a safe default, an explicit override
and a remote recovery path. Safe mode turns all of them off at once; sign-in,
browsing and playback are never optional.</p>
</div>
<button class="danger" id="feature-safe-mode">Enable safe mode</button>
</div>
<div class="tiles plain" id="feature-health"></div>
</section>
<div class="grid two" id="feature-list"></div>
<section class="card">
<div class="row">
<button class="primary" id="feature-save">Publish changes</button>
<button id="feature-rollback">Roll back one revision</button>
<button id="feature-reset">Clear all overrides</button>
<span id="feature-state"></span>
</div>
</section>
@@ -0,0 +1,93 @@
const { fmt, ui, $ } = Admin;
// The revision the page was last drawn from. Every mutation carries it, so two operators
// working at once cannot silently overwrite each other's publish.
let revision = 0;
function featureCard(feature) {
const mode = feature.source === 'override' ? (feature.enabled ? 'on' : 'off') : 'default';
return '<section class="card"><div class="card-head split"><div>' +
'<h2 class="card-title">' + fmt.escape(feature.name) + '</h2>' +
'<p class="card-note">' + fmt.escape(feature.description) + '</p></div>' +
ui.tag(feature.enabled ? 'active' : 'off', feature.enabled ? 'ok' : 'idle') + '</div>' +
'<label class="field"><span>Mode</span>' +
'<select data-feature-key="' + fmt.escape(feature.key) + '">' +
'<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></label>' +
'<div class="chips"><span class="chip mono">' + fmt.escape(feature.key) + '</span>' +
ui.chip('protocol ' + fmt.number(feature.minimumProtocol) + '+') +
ui.chip(feature.compatible ? 'server compatible' : 'compatibility blocked',
feature.compatible ? 'accent' : 'warn') +
ui.chip(feature.area) + '</div>' +
'<p class="hint">↳ ' + fmt.escape(feature.recovery) + '</p></section>';
}
Admin.onStatus((status) => {
const payload = status.features || {};
const clients = status.clients || [];
revision = Number(payload.revision || 0);
const features = payload.features || [];
const capable = clients.filter((client) =>
(client.capabilities || []).includes('server_features_v1')).length;
$('feature-health').innerHTML = ui.tiles([
['features active', features.filter((feature) => feature.enabled).length + ' / ' + features.length,
{ icon: 'sliders', tone: 'ok' }],
['explicit overrides', features.filter((feature) => feature.source === 'override').length,
{ icon: 'wrench', tone: 'warn' }],
['televisions reporting the control plane', capable + ' / ' + clients.length,
{ icon: 'tv', tone: 'info' }],
['published revision', 'r' + revision, { icon: 'history', tone: 'note' }],
]);
const safe = $('feature-safe-mode');
safe.textContent = payload.safeMode ? 'Leave safe mode' : 'Enable safe mode';
safe.className = payload.safeMode ? '' : 'danger';
$('feature-state').innerHTML = payload.safeMode
? ui.tag('safe mode · optional features off', 'warn')
: ui.tag('live · revision r' + revision, 'ok');
$('feature-rollback').disabled = !payload.canRollback;
// A redraw would take a half-made choice out from under the operator, and the selects are
// read back wholesale when Publish is pressed.
const list = $('feature-list');
if (!Admin.settled(list)) return;
list.innerHTML = features.map(featureCard).join('') ||
ui.empty('No server features are registered.');
});
const featureAction = (action, overrides = {}) => Admin.api('/admin/api/features', {
method: 'POST',
body: JSON.stringify({ action, expectedRevision: revision, overrides }),
});
Admin.ready(() => {
$('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;
});
Admin.act(() => featureAction('save', overrides));
});
$('feature-safe-mode').addEventListener('click', () => {
const leaving = $('feature-safe-mode').textContent.startsWith('Leave');
if (!leaving && !confirm('Disable every optional feature immediately? Core sign-in, ' +
'browsing and playback remain available.')) return;
Admin.act(() => featureAction(leaving ? 'leave-safe-mode' : 'safe-mode'));
});
$('feature-rollback').addEventListener('click', () => {
if (!confirm('Restore the previous published feature revision?')) return;
Admin.act(() => featureAction('rollback'));
});
$('feature-reset').addEventListener('click', () => {
if (!confirm('Clear every override and return all features to their safe software defaults?')) return;
Admin.act(() => featureAction('reset'));
});
});
@@ -0,0 +1,16 @@
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="sync" data-icon-tone="data">Synchronisation history</h2>
<p class="card-note">A full import mark-and-sweeps the catalogue; an incremental one asks
Emby for what changed, with a minute of overlap so nothing falls between two runs.</p>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Started</th><th>Kind</th><th>Trigger</th><th>Status</th>
<th class="num">Seen</th><th class="num">Written</th><th class="num">Removed</th><th>Notes</th>
</tr></thead>
<tbody id="import-rows"></tbody>
</table>
</div>
</section>
@@ -0,0 +1,16 @@
const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
const runs = status.runs || [];
$('import-rows').innerHTML = runs.length ? runs.map((run) => {
const tone = run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad';
return '<tr><td>' + fmt.escape(fmt.when(run.startedAt)) + '</td>' +
'<td>' + fmt.escape(run.kind) + '</td>' +
'<td>' + fmt.escape(run.trigger) + '</td>' +
'<td>' + ui.tag(run.status, tone) + '</td>' +
'<td class="num">' + fmt.number(run.itemsSeen) + '</td>' +
'<td class="num">' + fmt.number(run.itemsUpserted) + '</td>' +
'<td class="num">' + fmt.number(run.itemsRemoved) + '</td>' +
'<td class="muted">' + fmt.escape(run.error || '') + '</td></tr>';
}).join('') : ui.emptyRow(8, 'Nothing has been imported yet.');
});
@@ -0,0 +1,39 @@
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="search" data-icon-tone="info">Run a pressure test</h2>
<p class="card-note">Nothing is changed by running this. It scores the person's prepared
pool as the launcher would, in the context you choose.</p>
</div>
<div class="fields">
<label class="field"><span>Person</span>
<select id="inspector-user"><option value="">Choose a person…</option></select></label>
<label class="field"><span>Context</span>
<select id="inspector-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 class="field"><span>Available minutes</span>
<input id="inspector-minutes" type="number" min="0" max="360" value="0"></label>
<label class="field"><span>Evaluate at</span>
<input id="inspector-at" type="datetime-local"></label>
</div>
<div class="row">
<button class="primary" id="inspector-run">Run pressure test</button>
<span class="hint" id="inspector-hint">Choose a person to inspect their recommendations.</span>
</div>
</section>
<div class="tiles" id="inspector-tiles" hidden></div>
<section class="card" id="inspector-profile-card" hidden>
<div class="card-head">
<h2 class="card-title" data-icon="sparkle" data-icon-tone="note">Profile evidence</h2>
<p class="card-note">The strongest learned affinities, and every explicit action this
person has taken.</p>
</div>
<div id="inspector-profile"></div>
</section>
<div class="grid" id="inspector-results"></div>
@@ -0,0 +1,123 @@
const { fmt, ui, $ } = Admin;
// The user list rides along on the ordinary status poll; the inspection itself is only ever
// run on request, because it re-scores a whole pool.
Admin.onStatus((status) => {
const select = $('inspector-user');
if (!Admin.settled(select.parentElement)) return;
const chosen = select.value;
select.innerHTML = '<option value="">Choose a person…</option>' +
(status.requestUsers || []).map((user) => '<option value="' + fmt.escape(user.id) + '">' +
fmt.escape(user.username) + '</option>').join('');
select.value = chosen;
});
function affinities(profile) {
return [
['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],
].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 component(name, value) {
return ui.chip(name + '=' + (value >= 0 ? '+' : '') + Number(value).toFixed(3),
value < 0 ? 'bad' : undefined);
}
function resultCard(item, index) {
const explanation = item.explanation || {};
const components = Object.entries(explanation.components || {})
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
const exposure = item.exposure || {};
const facts = [item.type, item.year, item.runtimeMinutes ? item.runtimeMinutes + ' min' : null]
.concat(item.genres || []).filter(Boolean).join(' · ');
return '<section class="card"><div class="card-head split"><div>' +
'<h2 class="card-title">#' + (index + 1) + ' · ' + fmt.escape(item.title) + '</h2>' +
'<p class="card-note">' + fmt.escape(facts) + '</p></div>' +
'<span class="score">' + Number(explanation.total || 0).toFixed(3) + '</span></div>' +
'<p class="hint">' + fmt.escape(item.preparedReason || 'No legacy prepared explanation') +
(item.compatibilityLabel ? ' · ' + fmt.escape(item.compatibilityLabel) : '') + '</p>' +
'<div class="chips">' +
(explanation.reasonCodes || []).map((code) => ui.chip(code, 'accent')).join('') +
components.map(([name, value]) => component(name, value)).join('') + '</div>' +
'<details><summary>Pool, row and exposure detail</summary><p class="hint">' +
'Base rank ' + fmt.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 ' + fmt.number(exposure.impressions) +
' · focuses ' + fmt.number(exposure.focuses) +
' · selects ' + fmt.number(exposure.selects) + '</p>' +
'<div class="chips">' + (item.eligibleRows || [])
.map((row) => ui.chip(row, 'accent')).join('') + '</div>' +
(item.preparedEvidenceTitle
? '<p class="hint">Prepared evidence: ' + fmt.escape(item.preparedEvidenceTitle) + '</p>'
: '') +
'</details></section>';
}
function render(payload) {
const meta = payload.profileMeta || {};
const tiles = $('inspector-tiles');
tiles.hidden = false;
tiles.innerHTML = ui.tiles([
['prepared pool', fmt.number(payload.poolCandidates), { icon: 'database', tone: 'data' }],
['permission eligible', fmt.number(payload.permissionEligible), { icon: 'shield', tone: 'ok' }],
['ranked result', fmt.number((payload.items || []).length), { icon: 'sparkle', tone: 'note' }],
['source events', fmt.number(meta.sourceEvents), { icon: 'pulse', tone: 'info' }],
['algorithm', meta.algorithmVersion || '—', { small: true, icon: 'chip' }],
['pool built', fmt.when(meta.poolBuiltAt), { small: true, icon: 'clock' }],
]);
const top = affinities(payload.profile || {}).slice(0, 24);
const actions = payload.actions || [];
$('inspector-profile-card').hidden = false;
$('inspector-profile').innerHTML =
'<div class="chips">' + (top.length
? top.map((entry) => ui.chip(entry.dimension + ': ' + entry.name + ' ' +
(entry.weight >= 0 ? '+' : '') + entry.weight.toFixed(3) +
' · n=' + fmt.number(entry.evidence), entry.weight < 0 ? 'bad' : undefined)).join('')
: ui.empty('No repeated affinity evidence yet; cold-start priors apply.')) + '</div>' +
'<div class="chips">' + (actions.length
? actions.map((action) => ui.chip(action.action + ': ' +
(action.title || action.itemId), 'accent')).join('')
: ui.empty('No explicit recommendation actions.')) + '</div>';
const items = payload.items || [];
$('inspector-results').innerHTML = items.length
? items.map(resultCard).join('')
: ui.empty('No candidates survived this context, the explicit exclusions and the permission filter.');
}
async function run() {
const userId = $('inspector-user').value;
if (!userId) {
Admin.error('Choose a person to pressure-test.');
return;
}
const params = new URLSearchParams({
userId,
context: $('inspector-context').value,
minutes: $('inspector-minutes').value || '0',
limit: '100',
});
const at = $('inspector-at').value;
if (at) params.set('at', new Date(at).toISOString());
$('inspector-hint').textContent = 'Running the permission check and the scorer…';
try {
render(await Admin.api('/admin/api/recommendations?' + params.toString()));
$('inspector-hint').textContent = 'Scored at ' + new Date().toLocaleTimeString() + '.';
Admin.error('');
} catch (err) {
$('inspector-hint').textContent = 'Pressure test failed.';
Admin.error(err.message);
}
}
Admin.ready(() => $('inspector-run').addEventListener('click', run));
@@ -0,0 +1,15 @@
<div class="tiles" id="library-tiles"></div>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="library" data-icon-tone="data">Import the catalogue</h2>
<p class="card-note">Emby's catalogue is copied here so search and the recommendation
candidate pool can be answered from one indexed table. Watched, favourite and resume
state is deliberately not stored — that is per person and still comes from Emby live.</p>
</div>
<div class="row">
<button class="primary" id="sync-incremental">Sync new items</button>
<button id="sync-full">Full re-import</button>
<span class="hint" id="sync-hint"></span>
</div>
</section>
@@ -0,0 +1,29 @@
const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
const byType = status.library.byType || {};
$('library-tiles').innerHTML = ui.tiles([
['items', fmt.number(status.library.total), { icon: 'library', tone: 'data' }],
...Object.keys(byType).sort().map((type) => [type, fmt.number(byType[type]), { icon: 'list' }]),
['last import', fmt.when(status.library.lastSynced), { small: true, icon: 'clock' }],
]);
const running = status.syncRunning;
$('sync-incremental').disabled = running;
$('sync-full').disabled = running;
$('sync-hint').textContent = running
? 'Import running…'
: 'An incremental import runs automatically every ' + status.syncEvery + '.';
});
const sync = (kind) => Admin.api('/admin/api/sync', {
method: 'POST', body: JSON.stringify({ kind }),
});
Admin.ready(() => {
$('sync-incremental').addEventListener('click', () => Admin.act(() => sync('incremental')));
$('sync-full').addEventListener('click', () => {
if (!confirm('Re-import the entire library? This can take several minutes.')) return;
Admin.act(() => sync('full'));
});
});
+21
View File
@@ -0,0 +1,21 @@
<section class="card">
<div class="row bottom">
<label class="field narrow"><span>Level</span>
<select id="log-level" aria-label="Minimum event level">
<option value="DEBUG">Debug and above</option>
<option value="INFO" selected>Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select></label>
<label class="field grow"><span>Filter</span>
<input type="search" id="log-search"
placeholder="Person, television, title, component, path…"></label>
<button id="log-pause">Pause</button>
<button id="log-clear">Clear view</button>
<button id="log-export">Export JSON</button>
</div>
<div id="log" class="log" role="log" aria-live="polite">
<div class="log-empty">Waiting for server events…</div>
</div>
<p class="hint" id="log-stats">Connecting…</p>
</section>
+106
View File
@@ -0,0 +1,106 @@
const { fmt, $ } = Admin;
const state = { cursor: 0, records: [], dropped: 0, paused: false, fetching: false };
const ranks = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
// The same order the server's own console lines use — who and where first, the reason for
// the line last — so a log read here and a log read over SSH look alike. `version` is
// dropped: it is the same on every line and is reported once in the rail instead.
const fieldOrder = [
'component', 'user', 'device', 'client', 'protocol',
'method', 'path', 'status', 'duration',
];
function orderedFields(attributes) {
const rank = (key) => {
const at = fieldOrder.indexOf(key);
if (at >= 0) return at;
return key === 'error' ? 1000 : 100;
};
return Object.entries(attributes)
.filter(([key]) => key !== 'version')
.sort((a, b) => rank(a[0]) - rank(b[0]));
}
const text = (event) =>
[event.message, ...Object.entries(event.attributes || {}).flat()].join(' ').toLowerCase();
function render() {
const minimum = ranks[$('log-level').value] || 20;
const search = $('log-search').value.trim().toLowerCase();
const filtered = state.records.filter((event) =>
(ranks[event.level] || 0) >= minimum && (!search || text(event).includes(search)));
// Rendering every retained record at once can freeze a browser during an incident. Keep
// all of them available for filtering and export, but draw only the visible tail.
const visible = filtered.slice(-2500);
const log = $('log');
const pinned = log.scrollHeight - log.scrollTop - log.clientHeight < 50;
log.innerHTML = visible.length ? visible.map((event) => {
const attrs = orderedFields(event.attributes || {})
.map(([key, value]) => fmt.escape(key) + '=' + fmt.escape(value)).join(' ');
// The level is a class on the line as well as on its own column: scrolling a log is
// looking for the one line that is not INFO, and a coloured word four columns in is
// easy to scroll past.
return '<div class="log-line ' + fmt.escape(event.level) + '">' +
'<span class="log-time">' + fmt.escape(fmt.when(event.occurredAt)) + '</span>' +
'<span class="log-level ' + fmt.escape(event.level) + '">' + fmt.escape(event.level) + '</span>' +
'<span>' + fmt.escape(event.message) + '</span>' +
'<span class="log-attrs">' + attrs + '</span></div>';
}).join('') : '<div class="log-empty">No events match this filter.</div>';
if (pinned) log.scrollTop = log.scrollHeight;
$('log-stats').textContent =
fmt.number(state.records.length) + ' retained · ' + fmt.number(filtered.length) + ' matching' +
(visible.length < filtered.length ? ' · showing the latest ' + fmt.number(visible.length) : '') +
(state.dropped ? ' · ' + fmt.number(state.dropped) + ' overwritten before delivery' : '');
}
// The ring buffer is drained in pages until it is caught up, so a console opened after an
// incident sees what happened rather than only what happens next.
Admin.onRefresh(async () => {
if (state.paused || state.fetching) return;
state.fetching = true;
try {
let pages = 0;
let page;
do {
page = await Admin.api('/admin/api/events?after=' + state.cursor + '&limit=1000');
state.cursor = page.next || state.cursor;
state.dropped += page.dropped || 0;
if ((page.events || []).length) {
state.records.push(...page.events);
if (state.records.length > 20000) {
state.records.splice(0, state.records.length - 20000);
}
}
pages += 1;
} while (page.hasMore && pages < 20 && !state.paused);
render();
} finally {
state.fetching = false;
}
});
Admin.ready(() => {
$('log-pause').addEventListener('click', () => {
state.paused = !state.paused;
$('log-pause').textContent = state.paused ? 'Resume' : 'Pause';
if (!state.paused) Admin.refresh();
});
$('log-clear').addEventListener('click', () => {
state.records = [];
state.dropped = 0;
render();
});
$('log-export').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(state.records, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'memby-events-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
});
$('log-level').addEventListener('change', render);
$('log-search').addEventListener('input', render);
});
@@ -0,0 +1,19 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="power" data-icon-tone="warn">Gateway availability</h2>
<p class="card-note">Takes Memby offline for every television, independently of Emby.
Sign-in and all content calls answer 503 with the message below, and the television
shows it in place of the launcher rows. This console keeps working.</p>
</div>
<span id="maintenance-state"></span>
</div>
<label class="field"><span>Message shown on the television</span>
<em>Say what is happening and when it will be back. It is the only thing the viewer is
told.</em>
<input type="text" id="maintenance-message" placeholder="Back shortly — upgrading the server"></label>
<div class="card-foot">
<button class="danger" id="maintenance-on">Go offline</button>
<button id="maintenance-off">Bring back online</button>
</div>
</section>
@@ -0,0 +1,21 @@
const { ui, $ } = Admin;
Admin.onStatus((status) => {
const maintenance = status.maintenance || {};
$('maintenance-state').innerHTML = maintenance.enabled
? ui.tag('offline', 'bad') : ui.tag('online', 'ok');
Admin.fill($('maintenance-message'), maintenance.message || '');
});
const set = (enabled) => Admin.api('/admin/api/maintenance', {
method: 'POST',
body: JSON.stringify({ enabled, message: $('maintenance-message').value }),
});
Admin.ready(() => {
$('maintenance-on').addEventListener('click', () => {
if (!confirm('Take Memby offline for every television?')) return;
Admin.act(() => set(true));
});
$('maintenance-off').addEventListener('click', () => Admin.act(() => set(false)));
});
@@ -0,0 +1,46 @@
<div class="tiles" id="overview-tiles"></div>
<div class="grid two">
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="tv" data-icon-tone="info">What televisions are being told</h2>
<p class="card-note">The answers the gateway is giving every set right now.</p>
</div>
<div class="kv" id="overview-state"></div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="wrench" data-icon-tone="note">Integrations</h2>
<p class="card-note">The services this gateway leans on, and whether they answered.</p>
</div>
<div class="kv" id="overview-services"></div>
</section>
</div>
<div class="grid two">
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="sync" data-icon-tone="data">Latest imports</h2>
<p class="card-note">The last few catalogue synchronisations.</p>
</div>
<a class="crumb" href="/admin/imports">All imports</a>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Started</th><th>Kind</th><th>Status</th><th class="num">Written</th></tr></thead>
<tbody id="overview-runs"></tbody>
</table>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="chip" data-icon-tone="info">Process</h2>
<p class="card-note">The container this console is served from.</p>
</div>
<div class="tiles plain" id="overview-runtime"></div>
<p class="hint" id="overview-memory">Reading process statistics…</p>
</section>
</div>
@@ -0,0 +1,91 @@
/* The page an operator lands on. It answers one question is anything wrong and hands
off to the page that can do something about it. Nothing here is editable on purpose:
somewhere that both summarises and changes state is where an accidental click lives. */
const { fmt, ui, $ } = Admin;
function row(label, value) {
return '<div class="kv-row"><span>' + fmt.escape(label) + '</span><span>' + value + '</span></div>';
}
Admin.onStatus((status) => {
const features = status.features || {};
const featureList = features.features || [];
const clients = status.clients || [];
const online = clients.filter((client) => fmt.recent(client.lastSeen)).length;
// The marks are the areas these numbers belong to, in the tones the rest of the console
// uses for them: the library is teal wherever it is counted, a person is violet, a
// television is blue. They are the same on the pages these tiles link through to.
$('overview-tiles').innerHTML = ui.tiles([
['items in the library', fmt.number(status.library.total), { icon: 'library', tone: 'data' }],
['people signed in', fmt.number((status.requestUsers || []).length),
{ icon: 'people', tone: 'note' }],
['devices · ' + online + ' active now', fmt.number(clients.length),
{ icon: 'tv', tone: 'info' }],
['optional features on', featureList.filter((feature) => feature.enabled).length +
' / ' + featureList.length, { icon: 'sliders', tone: 'ok' }],
['last import', fmt.when(status.library.lastSynced), { small: true, icon: 'clock' }],
]);
const maintenance = status.maintenance || {};
const policy = status.updatePolicy || {};
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
const playback = status.playbackPolicy || {};
$('overview-state').innerHTML =
row('Availability', maintenance.enabled
? ui.tag('offline for maintenance', 'bad')
: ui.tag('online', 'ok')) +
row('Feature control plane', features.safeMode
? ui.tag('safe mode · optional features off', 'warn')
: ui.tag('revision r' + fmt.number(features.revision || 0), 'ok')) +
row('App update prompt', !policy.enabled
? ui.tag('off', 'idle')
: ui.tag((required ? 'required · ' : 'optional · ') + policy.latestVersion,
required ? 'warn' : 'ok')) +
row('Catalogue import', status.syncRunning
? ui.tag('running', 'warn')
: ui.tag('every ' + status.syncEvery, 'idle')) +
row('Playback preroll', playback.prerollEnabled === false
? ui.tag('off', 'idle')
: ui.tag(((playback.prerollDurationMs || 6500) / 1000) + 's', 'ok'));
const mdblist = status.mdblist || {};
const forYou = status.forYou || {};
$('overview-services').innerHTML =
row('Radarr', status.radarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('Sonarr', status.sonarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('MDBList ratings', mdblist.enabled
? ui.tag(fmt.number(mdblist.cachedTitles) + ' titles stored', 'ok')
: ui.tag(mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key', 'idle')) +
row('For You pools', status.forYouRunning
? ui.tag('rebuilding', 'warn')
: ui.tag(fmt.number(forYou.candidates) + ' ranked candidates', 'idle')) +
row('Recommendation profiles', '<span class="code">' + fmt.number(forYou.profiles) + '</span>');
const runs = (status.runs || []).slice(0, 5);
$('overview-runs').innerHTML = runs.length ? runs.map((run) => {
const tone = run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad';
return '<tr><td>' + fmt.escape(fmt.when(run.startedAt)) + '</td>' +
'<td>' + fmt.escape(run.kind) + '</td>' +
'<td>' + ui.tag(run.status, tone) + '</td>' +
'<td class="num">' + fmt.number(run.itemsUpserted) + '</td></tr>';
}).join('') : ui.emptyRow(4, 'No imports have run yet.');
});
// Process statistics are their own endpoint and their own tick: they are the one thing here
// that says nothing about the household and everything about the container.
Admin.onRefresh(async () => {
const runtime = await Admin.api('/admin/api/runtime');
$('overview-runtime').innerHTML = ui.tiles([
['goroutines', fmt.number(runtime.goroutines), { icon: 'pulse', tone: 'info' }],
['heap in use', fmt.bytes(runtime.heapInuse), { icon: 'chip', tone: 'info' }],
['reserved from the OS', fmt.bytes(runtime.sys), { icon: 'chip', tone: 'info' }],
['collections', fmt.number(runtime.numGc), { icon: 'sync', tone: 'info' }],
]);
const limit = runtime.memoryLimit > 0 && runtime.memoryLimit < Number.MAX_SAFE_INTEGER
? fmt.bytes(runtime.memoryLimit) + (runtime.configuredLimit ? ' (GOMEMLIMIT)' : '')
: 'no limit set';
$('overview-memory').textContent = 'Next collection at ' + fmt.bytes(runtime.nextGc) +
' · memory limit ' + limit + ' · ' + runtime.gomaxprocs + ' processors available.';
});
@@ -0,0 +1,20 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="play" data-icon-tone="info">Upcoming-show preroll</h2>
<p class="card-note">Sent with every playback launch. A change applies to the next
title opened on every gateway-connected television; no app release is required.</p>
</div>
<span id="playback-state"></span>
</div>
<label class="check">
<input type="checkbox" id="preroll-enabled">
<span>Show the preroll before a title starts</span>
</label>
<label class="field narrow"><span>Duration</span>
<em>Between 1 and 30 seconds. The stream is already playing behind it.</em>
<input type="number" id="preroll-duration" min="1" max="30" step="0.5" value="6.5"></label>
<div class="card-foot">
<button class="primary" id="playback-save">Save playback policy</button>
</div>
</section>
@@ -0,0 +1,27 @@
const { ui, $ } = Admin;
Admin.onStatus((status) => {
const playback = status.playbackPolicy || {};
Admin.check($('preroll-enabled'), playback.prerollEnabled !== false);
Admin.fill($('preroll-duration'), ((playback.prerollDurationMs || 6500) / 1000).toString());
$('playback-state').innerHTML = $('preroll-enabled').checked
? ui.tag('on · ' + $('preroll-duration').value + 's', 'ok')
: ui.tag('off', 'idle');
});
Admin.ready(() => {
$('playback-save').addEventListener('click', () => {
const seconds = Number($('preroll-duration').value);
if (!Number.isFinite(seconds) || seconds < 1 || seconds > 30) {
Admin.error('The preroll duration must be between 1 and 30 seconds.');
return;
}
Admin.act(() => Admin.api('/admin/api/playback-policy', {
method: 'POST',
body: JSON.stringify({
prerollEnabled: $('preroll-enabled').checked,
prerollDurationMs: Math.round(seconds * 1000),
}),
}));
});
});
@@ -0,0 +1,45 @@
<div class="tiles" id="ratings-tiles"></div>
<div class="grid two">
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="star" data-icon-tone="note">MDBList connection</h2>
<p class="card-note">The key stays on this server and a failure never blocks a
television. Every rating fetched is stored here permanently and re-checked about
once a month, so browsing the library costs nothing after the first look at a
title.</p>
</div>
<span id="mdblist-state"></span>
</div>
<label class="check">
<input type="checkbox" id="mdblist-enabled">
<span>Show external ratings on televisions<em>Off leaves the stored ratings in place.</em></span>
</label>
<label class="field"><span>API key</span>
<em>Leave blank to keep the key that is already saved.</em>
<input type="password" id="mdblist-api-key" autocomplete="new-password"
placeholder="Paste an API key"></label>
<label class="check">
<input type="checkbox" id="mdblist-clear-key"><span>Remove the saved key</span>
</label>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="list" data-icon-tone="data">Sources shown on televisions</h2>
<p class="card-note">A title with none of these has no ratings strip at all, which is
the honest answer — nothing stands in for a score that was never fetched.</p>
</div>
<div class="checks columns" id="mdblist-sources">
<p class="empty">Loading sources…</p>
</div>
</section>
</div>
<section class="card">
<div class="row">
<button class="primary" id="mdblist-save">Save ratings settings</button>
<span class="hint" id="mdblist-cache"></span>
</div>
</section>
@@ -0,0 +1,65 @@
const { fmt, ui, $ } = Admin;
// Display names for the providers MDBList answers with. An unknown key is shown as itself
// rather than hidden — a source the server offers and the console cannot name is still a
// source the operator may want on.
const sourceNames = {
imdb: 'IMDb', tomatoes: 'Rotten Tomatoes', audience: 'Rotten Tomatoes Audience',
metacritic: 'Metacritic', letterboxd: 'Letterboxd', rogerebert: 'Roger Ebert',
tmdb: 'TMDb', trakt: 'Trakt', mal: 'MyAnimeList', anilist: 'AniList',
anidb: 'AniDB', kitsu: 'Kitsu', score: 'MDBList Score', score_average: 'MDBList Average',
};
Admin.onStatus((status) => {
const mdblist = status.mdblist || {};
const cached = Number(mdblist.cachedTitles || 0);
$('ratings-tiles').innerHTML = ui.tiles([
['titles stored', fmt.number(cached), { icon: 'database', tone: 'data' }],
['due to be re-checked', fmt.number(mdblist.staleTitles), { icon: 'sync', tone: 'warn' }],
['sources shown', fmt.number((mdblist.sources || []).length), { icon: 'star', tone: 'note' }],
['API key', mdblist.apiKeyConfigured ? 'saved' : 'not set',
{ small: true, icon: 'key', tone: mdblist.apiKeyConfigured ? 'ok' : undefined }],
]);
Admin.check($('mdblist-enabled'), mdblist.enabled);
$('mdblist-state').innerHTML = mdblist.enabled
? ui.tag('on · ' + (mdblist.sources || []).length + ' sources', 'ok')
: ui.tag(mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key', 'idle');
$('mdblist-cache').textContent = cached
? 'Ratings are fetched as televisions browse, never on the request path.'
: 'No ratings stored yet. They are saved as televisions browse the library.';
const keyField = $('mdblist-api-key');
keyField.placeholder = mdblist.apiKeyConfigured
? 'Saved key (leave blank to keep)' : 'Paste an API key';
const sources = $('mdblist-sources');
if (!Admin.settled(sources)) return;
const selected = new Set(mdblist.sources || []);
sources.innerHTML = (mdblist.availableSources || []).map((source) =>
'<label class="check"><input type="checkbox" data-mdblist-source="' + fmt.escape(source) + '"' +
(selected.has(source) ? ' checked' : '') + '><span>' +
fmt.escape(sourceNames[source] || source) + '</span></label>').join('') ||
ui.empty('No rating sources are available.');
});
Admin.ready(() => {
$('mdblist-save').addEventListener('click', () => {
const sources = [...document.querySelectorAll('[data-mdblist-source]:checked')]
.map((box) => box.dataset.mdblistSource);
Admin.act(() => Admin.api('/admin/api/mdblist-settings', {
method: 'POST',
body: JSON.stringify({
enabled: $('mdblist-enabled').checked,
apiKey: $('mdblist-api-key').value.trim(),
clearApiKey: $('mdblist-clear-key').checked,
sources,
}),
}).then(() => {
$('mdblist-api-key').value = '';
$('mdblist-clear-key').checked = false;
}));
});
});
@@ -0,0 +1,28 @@
<div class="tiles" id="for-you-tiles"></div>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="sparkle" data-icon-tone="note">Pool maintenance</h2>
<p class="card-note">Prepared pools refresh in the background; these are the manual
versions of the same work. A rebuild is safe at any time — televisions read the last
finished pool until a new one lands.</p>
</div>
<div class="row">
<button class="primary" id="for-you-import">Import recent sessions</button>
<button id="for-you-full">Full Tracearr backfill</button>
<button id="for-you-rebuild">Rebuild all pools</button>
<span class="hint" id="for-you-hint"></span>
</div>
</section>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="search" data-icon-tone="info">Reading a person's scores</h2>
<p class="card-note">The inspector re-runs the shared weighted scorer over one
person's prepared pool, after Emby permission and parental-control filtering, and
shows every component and evidence reason behind the order.</p>
</div>
<a class="crumb" href="/admin/inspector">Open the inspector</a>
</div>
</section>
@@ -0,0 +1,33 @@
const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
const forYou = status.forYou || {};
$('for-you-tiles').innerHTML = ui.tiles([
['Tracearr sessions', fmt.number(forYou.tracearrSessions), { icon: 'play', tone: 'info' }],
['user profiles', fmt.number(forYou.profiles), { icon: 'people', tone: 'note' }],
['ranked candidates', fmt.number(forYou.candidates), { icon: 'sparkle', tone: 'note' }],
['last full import', fmt.when(forYou.lastFullImport), { small: true, icon: 'clock' }],
]);
const running = Boolean(status.forYouRunning);
['for-you-import', 'for-you-full', 'for-you-rebuild'].forEach((id) => {
$(id).disabled = running;
});
$('for-you-hint').textContent = running
? 'For You maintenance running…'
: 'Prepared pools normally refresh in the background.';
});
const action = (name) => Admin.api('/admin/api/for-you', {
method: 'POST', body: JSON.stringify({ action: name }),
});
Admin.ready(() => {
$('for-you-import').addEventListener('click', () =>
Admin.act(() => action('incremental-import')));
$('for-you-full').addEventListener('click', () => {
if (!confirm('Backfill all Tracearr history and rebuild every active user pool?')) return;
Admin.act(() => action('full-import'));
});
$('for-you-rebuild').addEventListener('click', () => Admin.act(() => action('rebuild-all')));
});
@@ -0,0 +1,24 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="inbox" data-icon-tone="info">Where a request goes</h2>
<p class="card-note">A film is added to Radarr and a show to Sonarr, both unmonitored.
No download search starts on its own.</p>
</div>
<span class="row tight" id="request-services"></span>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="people" data-icon-tone="note">Who sees “Request it”</h2>
<p class="card-note">The button appears at the end of a library search that found
nothing. Everyone else simply sees an empty result.</p>
</div>
<div class="checks columns" id="request-users">
<p class="empty">Loading people…</p>
</div>
<div class="card-foot">
<button class="primary" id="request-save">Save access</button>
</div>
</section>
@@ -0,0 +1,29 @@
const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
$('request-services').innerHTML =
ui.tag('Radarr ' + (status.radarrReady ? 'ready' : 'not configured'),
status.radarrReady ? 'ok' : 'bad') +
ui.tag('Sonarr ' + (status.sonarrReady ? 'ready' : 'not configured'),
status.sonarrReady ? 'ok' : 'bad');
const box = $('request-users');
if (!Admin.settled(box)) return;
const allowed = new Set((status.requestPolicy || {}).allowedUserIds || []);
box.innerHTML = (status.requestUsers || []).length
? status.requestUsers.map((user) =>
'<label class="check"><input type="checkbox" data-request-user="' + fmt.escape(user.id) + '"' +
(allowed.has(user.id) ? ' checked' : '') + '><span>' + fmt.escape(user.username) +
'<em>last seen ' + fmt.escape(fmt.when(user.lastSeen)) + '</em></span></label>').join('')
: ui.empty('No one has signed in yet.');
});
Admin.ready(() => {
$('request-save').addEventListener('click', () => {
const allowedUserIds = [...document.querySelectorAll('[data-request-user]:checked')]
.map((box) => box.dataset.requestUser);
Admin.act(() => Admin.api('/admin/api/request-policy', {
method: 'POST', body: JSON.stringify({ allowedUserIds }),
}));
});
});
@@ -0,0 +1,34 @@
<a class="crumb" href="/admin/accounts" id="history-crumb">← Back to the user</a>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="tv" data-icon-tone="info">Where each device has got to</h2>
<p class="card-note">A set takes a change by fetching it, which it does within a few
seconds of being told — so anything still behind is switched off, mid-film, or
cannot reach the gateway.</p>
</div>
<span id="history-current"></span>
</div>
<div class="list" id="history-devices"></div>
</section>
<section class="card flush">
<div class="card-head">
<h2 class="card-title" data-icon="history" data-icon-tone="note">Change history</h2>
<p class="card-note">Restoring puts an earlier version back as a new change, so the
televisions notice it and the version it replaced stays here to return to.</p>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>When</th><th>Rev</th><th>Changed by</th><th>What changed</th>
<th class="num">Taken by</th><th></th>
</tr></thead>
<tbody id="history-rows"></tbody>
</table>
</div>
<div class="card-foot">
<span class="hint" id="history-message"></span>
</div>
</section>
@@ -0,0 +1,175 @@
/* One person's settings, over time. The identity is in the path /admin/accounts/<id>/
settings so the page can be linked and returned to after a sign-in, like every other
page here.
The two questions this answers are different and both need the whole page. "What did I
change and can I undo it" is the table; "why is that television still wrong" is the
device list above it, because a revision the server wrote is not a revision a set has
taken. */
const { fmt, ui, $ } = Admin;
const segments = location.pathname.split('/').filter(Boolean);
// .../accounts/<id>/settings — the id is the segment before the page name.
const userId = decodeURIComponent(segments[segments.length - 2] || '');
const base = '/admin/api/accounts/' + encodeURIComponent(userId);
// Which revisions have their full document open. Kept out of the DOM so the thirty-second
// poll can redraw the table without closing what the operator opened.
const expanded = new Set();
let catalogue = [];
let latest = null;
function message(text) { $('history-message').textContent = text || ''; }
/* ---- where each television has got to ---------------------------------- */
function renderDevices(payload) {
const devices = payload.devices || [];
const current = payload.currentRevision || 0;
$('history-current').innerHTML = payload.saved
? ui.tag('now on r' + fmt.number(current) + ' · ' + (payload.currentSource || 'device'),
payload.currentSource === 'admin' ? 'warn' : 'ok')
: ui.tag('defaults · never synced', 'idle');
$('history-devices').innerHTML = devices.length ? devices.map((device) => {
const tone = device.never ? 'idle' : device.behind ? 'bad' : 'ok';
const state = device.never
? ui.tag('never taken one', 'idle')
: device.behind
? ui.tag(fmt.number(device.behind) + ' behind', 'bad')
: ui.tag('up to date', 'ok');
const held = device.never
? 'Has not fetched these settings yet'
: 'Holding r' + fmt.number(device.revision) + ' · taken ' + fmt.when(device.ackedAt);
return '<div class="list-row"><span class="list-main"><span>' +
'<span class="list-title">' +
'<span class="dot" data-tone="' + tone + '"></span>' +
fmt.escape(device.name || 'Memby TV') +
(device.signedOut ? ' ' + ui.tag('signed out', 'idle') : '') +
'</span>' +
'<span class="list-meta">' + fmt.escape(held) +
(device.clientVersion ? ' · Memby ' + fmt.escape(device.clientVersion) : '') +
(device.signedOut ? '' : ' · last seen ' + fmt.escape(fmt.when(device.lastSeen))) +
'</span></span></span>' +
'<span class="list-actions">' + state + '</span></div>';
}).join('') : ui.empty('No television has been signed in to this account.');
}
/* ---- the history table -------------------------------------------------- */
function changeChips(revision) {
if (revision.initial) return '<span class="muted">First recorded settings</span>';
const changes = revision.changes || [];
if (!changes.length) {
// A write that changed nothing an operator can see: a television pushing the document
// it already held, usually. Saying so is more useful than an empty cell.
return '<span class="muted">No visible change</span>';
}
return '<div class="chips">' + changes.map((change) =>
ui.chip(change.name + ': ' + change.before + ' → ' + change.after)).join('') + '</div>';
}
function takenBy(revision) {
const acks = revision.acks || [];
if (!acks.length) return '<span class="muted">—</span>';
const names = acks.map((ack) => ack.deviceName || ack.deviceId).join(', ');
return '<span title="' + fmt.escape(names) + '">' + fmt.number(acks.length) + '</span>';
}
// The whole document at one revision, in the catalogue's own order and wording. This is
// what makes a restore a decision rather than a guess.
function documentRow(revision) {
const values = revision.preferences || {};
const chips = catalogue.map((definition) =>
ui.chip(definition.name + ': ' + describe(definition, values[definition.key])));
return '<tr class="detail"><td colspan="6" class="muted"><div class="chips">' +
chips.join('') + '</div></td></tr>';
}
// The same wording the server puts in a change line, so a document and a diff never
// describe one value two ways.
function describe(definition, value) {
if (definition.kind === 'toggle') return value ? 'On' : 'Off';
if (definition.kind === 'choice') {
const match = (definition.options || []).find((option) => option.value === value);
return match ? match.label : String(value ?? '');
}
if (definition.kind === 'number') {
if (Number(value) === 0 && definition.unit) return 'No limit';
return definition.unit ? value + ' ' + definition.unit : String(value ?? '');
}
const entries = Array.isArray(value) ? value : [];
if (!entries.length) return 'None';
return entries.map((entry) => {
const match = (definition.options || []).find((option) => option.value === entry);
return match ? match.label : entry;
}).join(', ');
}
function renderHistory(payload) {
const revisions = payload.revisions || [];
$('history-rows').innerHTML = revisions.length ? revisions.map((revision) => {
const open = expanded.has(revision.revision);
const source = revision.source === 'admin' ? 'warn' : 'ok';
const row = '<tr>' +
'<td>' + fmt.escape(fmt.when(revision.createdAt)) + '</td>' +
'<td class="num">r' + fmt.number(revision.revision) +
(revision.current ? ' ' + ui.tag('current', 'ok') : '') + '</td>' +
'<td>' + ui.tag(revision.author, source) +
(revision.restoredFrom
? ' <span class="muted">restored r' + fmt.number(revision.restoredFrom) + '</span>'
: '') + '</td>' +
'<td class="muted">' + changeChips(revision) + '</td>' +
'<td class="num">' + takenBy(revision) + '</td>' +
'<td class="num"><span class="list-actions">' +
'<button class="small" data-history-action="toggle" data-revision="' +
revision.revision + '">' + (open ? 'Hide' : 'Show') + '</button>' +
(revision.current ? ''
: '<button class="small" data-history-action="restore" data-revision="' +
revision.revision + '">Restore</button>') +
'</span></td></tr>';
return open ? row + documentRow(revision) : row;
}).join('') : ui.emptyRow(6, 'Nothing has been changed on this account yet.');
}
/* ---- page --------------------------------------------------------------- */
Admin.onRefresh(async () => {
const payload = await Admin.api(base + '/preferences/history');
latest = payload;
catalogue = payload.catalogue || catalogue;
const name = payload.username || 'this account';
$('page-title').textContent = 'Settings history';
$('page-intro').textContent = 'Every change to ' + name + 's synced settings, and which ' +
'of their televisions has taken it.';
document.title = name + ' · settings history · Memby admin';
$('history-crumb').href = '/admin/accounts/' + encodeURIComponent(userId);
$('history-crumb').textContent = '← ' + name;
renderDevices(payload);
renderHistory(payload);
});
document.addEventListener('click', (event) => {
const button = event.target.closest('[data-history-action]');
if (!button) return;
const revision = Number(button.dataset.revision);
if (button.dataset.historyAction === 'toggle') {
if (expanded.has(revision)) expanded.delete(revision); else expanded.add(revision);
if (latest) renderHistory(latest);
return;
}
if (button.dataset.historyAction === 'restore') {
if (!confirm('Restore revision ' + revision + '? It goes out as a new change, so every ' +
'one of their televisions will pick it up, and the current version stays in this ' +
'history to return to.')) return;
message('restoring…');
Admin.act(async () => {
await Admin.api(base + '/preferences/revisions/' + revision + '/restore',
{ method: 'POST' });
message('restored r' + revision);
});
}
});
@@ -0,0 +1,29 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="download" data-icon-tone="info">Update policy</h2>
<p class="card-note">Televisions check on every launch. An optional update is a prompt
the viewer can dismiss; a required one covers the home screen until they update, so
it needs a download URL that actually works.</p>
</div>
<span id="update-state"></span>
</div>
<div class="fields">
<label class="field"><span>Latest version</span>
<input type="text" id="update-version" placeholder="0.1.54"></label>
<label class="field"><span>APK URL</span>
<input type="text" id="update-url" placeholder="https://nas/memby/memby-0.1.54.apk"></label>
</div>
<label class="field"><span>What's new</span>
<em>Shown on the television above the update button.</em>
<input type="text" id="update-notes" placeholder="One line the viewer reads"></label>
<label class="check">
<input type="checkbox" id="update-required">
<span>Require this update<em>Blocks the home screen on every television below this
version.</em></span>
</label>
<div class="card-foot">
<button class="primary" id="update-save">Save policy</button>
<button id="update-disable">Turn prompts off</button>
</div>
</section>
@@ -0,0 +1,36 @@
const { ui, $ } = Admin;
Admin.onStatus((status) => {
const policy = status.updatePolicy || {};
// "Required" is not a field of its own: it is the minimum and the latest being the same
// version, which is what the client compares against.
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
$('update-state').innerHTML = !policy.enabled
? ui.tag('off', 'idle')
: ui.tag((required ? 'required · ' : 'optional · ') + policy.latestVersion,
required ? 'warn' : 'ok');
Admin.fill($('update-version'), policy.latestVersion || '');
Admin.fill($('update-url'), policy.downloadUrl || '');
Admin.fill($('update-notes'), policy.notes || '');
Admin.check($('update-required'), required);
});
const body = (enabled) => JSON.stringify({
enabled,
latestVersion: $('update-version').value.trim(),
downloadUrl: $('update-url').value.trim(),
notes: $('update-notes').value.trim(),
required: $('update-required').checked,
});
Admin.ready(() => {
$('update-save').addEventListener('click', () => {
if ($('update-required').checked && !confirm('Required updates block the home screen on ' +
'every television below this version. Continue?')) return;
Admin.act(() => Admin.api('/admin/api/update-policy', { method: 'POST', body: body(true) }));
});
$('update-disable').addEventListener('click', () =>
Admin.act(() => Admin.api('/admin/api/update-policy', { method: 'POST', body: body(false) })));
});
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Page.Title}} · Memby admin</title>
<style>{{.CSS}}</style>
</head>
<body>
<a class="skip" href="#page">Skip to content</a>
<aside class="rail" aria-label="Admin navigation">
<a class="rail-brand" href="/admin/overview">
<span class="rail-mark" aria-hidden="true">M</span>
<span class="rail-brand-copy"><b>Memby</b><span>Gateway admin</span></span>
</a>
<div class="rail-scroll">
{{range .Nav}}
{{if .Label}}<p class="rail-group">{{.Label}}</p>{{end}}
<nav class="rail-nav">
{{range .Items}}{{if not .Hidden}}
<a class="rail-link{{if eq .ID $.Page.ID}} active{{end}}" href="/admin/{{.ID}}"
{{if eq .ID $.Page.ID}}aria-current="page"{{end}} title="{{.Label}}">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="{{.Icon}}"/></svg><span>{{.Label}}</span>
</a>
{{end}}{{end}}
</nav>
{{end}}
</div>
<div class="rail-foot">
<span class="dot" id="rail-live" data-tone="idle"></span>
<span class="rail-foot-copy"><b id="rail-live-label">connecting</b><span id="rail-version">gateway …</span></span>
</div>
</aside>
<main id="page" class="page" data-admin-page="{{.Page.ID}}">
<header class="page-head">
<div class="page-head-copy">
<h1 id="page-title">
{{if .Page.Icon}}<span class="glyph page-mark" data-tone="accent" aria-hidden="true">
<svg class="ico" viewBox="0 0 24 24"><path d="{{.Page.Icon}}"/></svg>
</span>{{end}}{{.Page.Title}}
</h1>
<p id="page-intro">{{.Page.Intro}}</p>
</div>
<span class="tag" id="live" data-tone="idle">connecting…</span>
</header>
<div id="error" class="notice" role="alert" hidden></div>
{{.Body}}
</main>
<script>{{.Core}}</script>
<script>{{.Script}}</script>
</body>
</html>
+157 -4
View File
@@ -20,11 +20,13 @@ type adminOnboardingRating struct {
type adminOnboardingPreferences struct {
Completed bool `json:"completed"`
Prompted bool `json:"prompted"`
Updated bool `json:"updated"`
Ratings []adminOnboardingRating `json:"ratings"`
Genres []string `json:"genres"`
Studios []string `json:"studios"`
Actors []string `json:"actors"`
Actresses []string `json:"actresses"`
Directors []string `json:"directors"`
ContentTypes []string `json:"contentTypes"`
}
@@ -36,12 +38,26 @@ type adminMembyAccount struct {
LastSeen time.Time `json:"lastSeen"`
Devices []store.MembyDevice `json:"devices"`
Recommendations adminOnboardingPreferences `json:"recommendations"`
// Settings is the same document the television reads, normalised the same way, so
// the console is editing what the TV will actually receive rather than a projection
// of it. Saved is false for someone who has never synced — the values shown are then
// the defaults, and saying so is the difference between "chose this" and "has not
// chosen anything".
Settings adminAccountSettings `json:"settings"`
}
type adminAccountSettings struct {
Saved bool `json:"saved"`
Revision int64 `json:"revision"`
Source string `json:"source,omitempty"`
UpdatedAt any `json:"updatedAt,omitempty"`
Preferences map[string]any `json:"preferences"`
}
func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
accounts, err := s.store.MembyAccounts(r.Context())
if err != nil {
s.log.Error("Memby account list failed", "error", err)
s.loggerFor(r.Context()).Error("Memby account list failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not load Memby accounts")
return
}
@@ -63,6 +79,14 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
}
}
settings, err := s.store.AllUserPreferences(r.Context())
if err != nil {
// A settings read failure must not cost the operator the account list; the
// devices and the sign-out buttons are what this page is for.
s.loggerFor(r.Context()).Warn("account settings read failed", "error", err)
settings = map[string]store.UserPreferences{}
}
result := make([]adminMembyAccount, 0, len(accounts))
for _, account := range accounts {
pref := preferences[account.ID]
@@ -80,18 +104,76 @@ func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
}
return strings.ToLower(ratings[i].Title) < strings.ToLower(ratings[j].Title)
})
stored, saved := settings[account.ID]
accountSettings := adminAccountSettings{
Saved: saved, Revision: stored.Revision, Source: stored.Source,
Preferences: decodePreferences(stored.Preferences),
}
if !stored.UpdatedAt.IsZero() {
accountSettings.UpdatedAt = stored.UpdatedAt
}
result = append(result, adminMembyAccount{
ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
LastSeen: account.LastSeen, Devices: account.Devices,
LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
Recommendations: adminOnboardingPreferences{
Completed: pref.Completed, Updated: len(account.RecommendationPreferences) > 2,
Completed: pref.Completed, Prompted: pref.Prompted,
Updated: len(account.RecommendationPreferences) > 2,
Ratings: ratings, Genres: nonNilStrings(pref.Genres),
Studios: nonNilStrings(pref.Studios), Actors: nonNilStrings(pref.Actors),
Actresses: nonNilStrings(pref.Actresses),
Directors: nonNilStrings(pref.Directors), ContentTypes: nonNilStrings(pref.ContentTypes),
},
})
}
writeJSON(w, http.StatusOK, map[string]any{"accounts": result})
// The catalogue rides along so the console builds its editor from the server's own
// vocabulary. A page that hard-coded the controls would drift from what the TV
// accepts the first time a setting is added, and would do it silently.
writeJSON(w, http.StatusOK, map[string]any{
"accounts": result, "catalogue": preferenceCatalogue,
"schemaVersion": preferenceSchemaVersion,
})
}
func (s *Server) handleAdminPromptRecommendations(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
accounts, err := s.store.MembyAccounts(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load Memby account")
return
}
found := false
for _, account := range accounts {
if account.ID == userID {
found = true
break
}
}
if !found {
writeError(w, http.StatusNotFound, "Memby account not found")
return
}
var preferences recommend.OnboardingPreferences
if raw, err := s.store.RecommendationOnboarding(r.Context(), userID); err == nil {
_ = json.Unmarshal(raw, &preferences)
} else {
writeError(w, http.StatusInternalServerError, "could not load recommendation choices")
return
}
if preferences.Completed {
writeError(w, http.StatusConflict, "recommendation setup is already complete")
return
}
preferences.Prompted = true
raw, _ := json.Marshal(preferences)
if err := s.store.SetRecommendationOnboarding(r.Context(), userID, raw); err != nil {
writeError(w, http.StatusInternalServerError, "could not queue recommendation prompt")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) handleAdminRenameDevice(w http.ResponseWriter, r *http.Request) {
@@ -134,6 +216,11 @@ func (s *Server) handleAdminDeleteDevice(w http.ResponseWriter, r *http.Request)
if s.cache != nil {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(hash)))
}
s.retireEmbyDevice(r.Context(), deviceID)
if err := s.store.DeleteDeviceVersions(r.Context(), deviceID); err != nil {
s.loggerFor(r.Context()).Warn("device version cleanup failed",
"removed_device_id", deviceID, "error", err)
}
w.WriteHeader(http.StatusNoContent)
}
@@ -163,6 +250,72 @@ func (s *Server) handleAdminDeleteAccount(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusNoContent)
}
type adminPreferencesRequest struct {
Preferences map[string]any `json:"preferences"`
}
// handleAdminPushPreferences is the operator writing someone's TV settings for them.
//
// It writes with store.ForceRevision on purpose. Every one of that person's televisions
// is also a writer of this row, and making the console lose a revision race would mean an
// operator's deliberate change being quietly reverted by whichever set happened to sync
// next. The push wins; the televisions notice on their next status poll and adopt it.
func (s *Server) handleAdminPushPreferences(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
var req adminPreferencesRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
raw, err := json.Marshal(normalizePreferences(req.Preferences))
if err != nil {
writeError(w, http.StatusBadRequest, "could not read those settings")
return
}
stored, err := s.store.SetUserPreferences(r.Context(), userID, store.PreferenceWrite{
Preferences: raw, ExpectedRevision: store.ForceRevision, Source: "admin",
})
if err != nil {
s.loggerFor(r.Context()).Error("admin preferences push failed", "user", userID, "error", err)
writeError(w, http.StatusInternalServerError, "could not push those settings")
return
}
s.loggerFor(r.Context()).Info("settings pushed to viewer",
"user", userID, "revision", stored.Revision, "source", "admin")
writeJSON(w, http.StatusOK, preferencesPayload(stored))
}
// handleAdminResetPreferences puts someone back on the defaults. It is a write rather than
// a delete so it still bumps the revision — a delete would leave every television holding
// the old document with nothing to tell them it had gone.
func (s *Server) handleAdminResetPreferences(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
raw, err := json.Marshal(normalizePreferences(nil))
if err != nil {
writeError(w, http.StatusInternalServerError, "could not build default settings")
return
}
stored, err := s.store.SetUserPreferences(r.Context(), userID, store.PreferenceWrite{
Preferences: raw, ExpectedRevision: store.ForceRevision, Source: "admin",
})
if err != nil {
s.loggerFor(r.Context()).Error("admin preferences reset failed", "user", userID, "error", err)
writeError(w, http.StatusInternalServerError, "could not reset those settings")
return
}
s.loggerFor(r.Context()).Info("settings reset to defaults",
"user", userID, "revision", stored.Revision, "source", "admin")
writeJSON(w, http.StatusOK, preferencesPayload(stored))
}
func (s *Server) handleAdminResetRecommendations(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
+248
View File
@@ -0,0 +1,248 @@
package api
import (
"bytes"
"embed"
"fmt"
"html/template"
"io/fs"
"path"
"sort"
)
// The console used to be one HTML file holding every screen at once: a television opening
// /admin/logs was still sent the accounts settings editor, the feature grid and the
// recommendation inspector, all hidden. That is why it read as one page wearing twelve
// hats. It is now a shell plus one fragment per page, composed here at start-up, so a page
// carries only its own markup and its own script — and so the rail, the page titles and
// the set of legal URLs all come from adminNav rather than being written out three times.
//
// The no-build, no-CDN rule is unchanged: everything below is embedded in the binary and
// inlined into the response. Nothing is fetched from anywhere.
//go:embed admin/shell.html admin/admin.css admin/core.js admin/pages
var adminAssets embed.FS
// adminNavItem is one destination. Hidden items are reachable and titled but are not in
// the rail — an account's own page belongs to the person it is about, not to a menu.
type adminNavItem struct {
ID string
Label string
Title string
Intro string
Icon string // the `d` of a single stroked path, drawn on a 24×24 grid
Hidden bool
}
type adminNavGroup struct {
Label string
Items []adminNavItem
}
// adminNav is the console's table of contents and the only place a page is declared.
// Adding a page is an entry here plus admin/pages/<id>.html and admin/pages/<id>.js.
var adminNav = []adminNavGroup{
{
Items: []adminNavItem{
{
ID: "overview", Label: "Overview", Title: "Overview",
Intro: "What the gateway is doing right now.",
Icon: "M4 13h6V4H4zm0 7h6v-4H4zm10 0h6v-9h-6zm0-16v4h6V4z",
},
},
},
{
Label: "People",
Items: []adminNavItem{
{
ID: "accounts", Label: "Users", Title: "Memby users",
Intro: "Who uses Memby, and the devices they are signed in on.",
Icon: "M16 19v-1.5A3.5 3.5 0 0 0 12.5 14h-5A3.5 3.5 0 0 0 4 17.5V19M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-2h3m-1.5-1.5v3",
},
{
ID: "account", Title: "User", Hidden: true,
Intro: "Devices, recommendation setup and synced settings for one person.",
},
{
ID: "settings-history", Title: "Settings history", Hidden: true,
Intro: "Every change to one person's synced settings, and which devices took it.",
},
{
ID: "clients", Label: "Devices", Title: "Devices",
Intro: "Which sets have reported in, what they are running and what their build understands.",
Icon: "M4 5h16v10H4zM9 19h6M12 15v4",
},
},
},
{
Label: "Content",
Items: []adminNavItem{
{
ID: "library", Label: "Library", Title: "Library",
Intro: "Import and inspect the catalogue Memby ranks.",
Icon: "M4 5.5h16v13H4zM8 5.5v13M4 10h4",
},
{
ID: "ratings", Label: "Movie ratings", Title: "Movie ratings",
Intro: "Optional MDBList scores on films and shows.",
Icon: "m12 3 2.1 5.4 5.9.4-4.6 3.8 1.5 5.7-4.9-3.2-4.9 3.2 1.5-5.7L4 8.8l5.9-.4L12 3Z",
},
{
ID: "requests", Label: "Media requests", Title: "Media requests",
Intro: "Who can ask for something the library does not have.",
Icon: "M5 4h14v16H5zM8 8h8M8 12h5M15 16h3m-1.5-1.5v3",
},
},
},
{
Label: "Personalisation",
Items: []adminNavItem{
{
ID: "recommendations", Label: "For You", Title: "For You",
Intro: "The prepared pools personalised rows are drawn from.",
Icon: "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",
},
{
ID: "inspector", Label: "Score inspector", Title: "Score inspector",
Intro: "Re-run the ranker for one person and read every component.",
Icon: "M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20",
},
},
},
{
Label: "Experience",
Items: []adminNavItem{
{
ID: "features", Label: "Features", Title: "Features",
Intro: "Roll out, stop and recover optional behaviour with no app release.",
Icon: "M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6",
},
{
ID: "playback", Label: "Playback", Title: "Playback",
Intro: "Presentation policy sent with every playback launch.",
Icon: "M8 5v14l11-7zM4 5v14",
},
{
ID: "updates", Label: "App updates", Title: "App updates",
Intro: "Publish an optional or a required client update.",
Icon: "M12 16V4m0 0L8 8m4-4 4 4M5 13v6h14v-6",
},
},
},
{
Label: "Operations",
Items: []adminNavItem{
{
ID: "maintenance", Label: "Maintenance", Title: "Maintenance",
Intro: "Take Memby offline for every television.",
Icon: "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",
},
{
ID: "imports", Label: "Imports", Title: "Imports",
Intro: "Catalogue synchronisation history.",
Icon: "M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5",
},
{
ID: "engagement", Label: "Row engagement", Title: "Row engagement",
Intro: "Impressions, focus, dwell and selections per launcher row.",
Icon: "M4 19V9m5 10V5m5 14v-7m5 7V3",
},
{
ID: "logs", Label: "Server logs", Title: "Server logs",
Intro: "Structured gateway events as they happen.",
Icon: "M4 5h16v14H4zM7 9l2 2-2 2m5 1h5",
},
},
},
}
// adminPages is the set of legal /admin/<page> URLs, derived so a page cannot exist in the
// rail and 404 — or be reachable and unnamed. installer_auth reads it to decide which
// destination a sign-in may return to, which is why hidden pages are excluded: they are
// addressed by a route that carries something else in the path, and a sign-in that returned
// to one without it would land on a page about nobody.
var adminPages = func() map[string]bool {
pages := map[string]bool{}
forEachAdminPage(func(item adminNavItem) {
if !item.Hidden {
pages[item.ID] = true
}
})
return pages
}()
func forEachAdminPage(visit func(adminNavItem)) {
for _, group := range adminNav {
for _, item := range group.Items {
visit(item)
}
}
}
type adminShellData struct {
Page adminNavItem
Nav []adminNavGroup
CSS template.CSS
Core template.JS
Body template.HTML
Script template.JS
}
// adminRendered holds every page as finished bytes. Composition happens once, at start-up,
// so serving a page is a write of a []byte exactly as it was when the whole console was
// one file.
var adminRendered = buildAdminPages()
func buildAdminPages() map[string][]byte {
shell := template.Must(template.New("shell").ParseFS(adminAssets, "admin/shell.html"))
css := template.CSS(mustReadAdminAsset("admin/admin.css"))
core := template.JS(mustReadAdminAsset("admin/core.js"))
rendered := map[string][]byte{}
forEachAdminPage(func(item adminNavItem) {
var out bytes.Buffer
err := shell.ExecuteTemplate(&out, "shell.html", adminShellData{
Page: item, Nav: adminNav, CSS: css, Core: core,
Body: template.HTML(mustReadAdminAsset(path.Join("admin/pages", item.ID+".html"))),
Script: template.JS(mustReadAdminAsset(path.Join("admin/pages", item.ID+".js"))),
})
if err != nil {
panic(fmt.Sprintf("admin console: render %s: %v", item.ID, err))
}
rendered[item.ID] = out.Bytes()
})
assertEveryAdminFragmentIsRouted(rendered)
return rendered
}
func mustReadAdminAsset(name string) string {
data, err := adminAssets.ReadFile(name)
if err != nil {
panic(fmt.Sprintf("admin console: %v", err))
}
return string(data)
}
// A fragment nobody routes to is dead weight that still looks maintained. Catching it here
// means a page removed from adminNav takes its files with it, or fails at start-up.
func assertEveryAdminFragmentIsRouted(rendered map[string][]byte) {
entries, err := fs.ReadDir(adminAssets, "admin/pages")
if err != nil {
panic(fmt.Sprintf("admin console: %v", err))
}
var orphaned []string
for _, entry := range entries {
name := entry.Name()
if path.Ext(name) != ".html" {
continue
}
id := name[:len(name)-len(".html")]
if _, ok := rendered[id]; !ok {
orphaned = append(orphaned, id)
}
}
if len(orphaned) > 0 {
sort.Strings(orphaned)
panic(fmt.Sprintf("admin console: pages with no nav entry: %v", orphaned))
}
}
+300
View File
@@ -0,0 +1,300 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The settings history: every revision of one person's synced settings, what each one
// changed, which televisions have taken it, and how to put an old one back.
//
// It exists because the revision is a delivery mechanism with no receipt. /v1/status hands
// every open television a number and the set fetches the document when it differs — which
// works, and tells the operator nothing. "I changed their card size an hour ago and the
// bedroom TV still looks wrong" has three possible answers (it never fetched, it fetched
// and something else has since overwritten it, or it is fine and the complaint is about
// something else) and no way to tell them apart. Acks are what separate them.
//
// Nothing here rewinds a revision. A restore is a *new* revision carrying an old document,
// for the same reason the counter only ever goes up: a television compares numbers, so one
// that went backwards would leave every set in the house believing it was already up to
// date and holding settings the operator had just replaced.
// adminHistoryLimit is how many revisions the page asks for. The store caps it too; this
// is the page saying what it can usefully draw.
const adminHistoryLimit = 60
type adminPreferenceRevision struct {
Revision int64 `json:"revision"`
Source string `json:"source"`
// Author is who made the change, in the console's words: a television's name, or the
// admin console itself. Recorded at write time, so a set signed out since still has
// a name here.
Author string `json:"author"`
DeviceID string `json:"deviceId,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
RestoredFrom int64 `json:"restoredFrom,omitempty"`
CreatedAt time.Time `json:"createdAt"`
Current bool `json:"current"`
Changes []preferenceChange `json:"changes"`
// Initial marks the first revision this history holds. Its "changes" are measured
// against the defaults, and calling that a change would be a claim about a decision
// nobody made — so the page labels it rather than listing it.
Initial bool `json:"initial"`
Preferences map[string]any `json:"preferences"`
Acks []store.PreferenceAck `json:"acks"`
}
// adminPreferenceDevice is one television's position: which revision it holds, and how far
// that is behind the current one.
type adminPreferenceDevice struct {
DeviceID string `json:"deviceId"`
Name string `json:"name"`
ClientVersion string `json:"clientVersion,omitempty"`
LastSeen time.Time `json:"lastSeen"`
SignedOut bool `json:"signedOut"`
Revision int64 `json:"revision"`
AckedAt any `json:"ackedAt,omitempty"`
// Behind is how many revisions this set has yet to take. Zero is up to date; the
// field is what the page sorts and colours by.
Behind int64 `json:"behind"`
// Never is a set that has never fetched the document at all — a fresh sign-in, or a
// build that predates synced settings. Distinct from being behind, because there is
// nothing to roll back to for it.
Never bool `json:"never"`
}
// handleAdminPreferenceHistory answers the whole page in one request: the revisions, what
// each changed, and where every television has got to.
func (s *Server) handleAdminPreferenceHistory(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
if userID == "" {
writeError(w, http.StatusBadRequest, "user is required")
return
}
history, err := s.store.UserPreferenceHistory(r.Context(), userID, adminHistoryLimit)
if err != nil {
s.loggerFor(r.Context()).Error("preference history read failed", "user", userID, "error", err)
writeError(w, http.StatusInternalServerError, "could not read the settings history")
return
}
current, err := s.store.UserPreferences(r.Context(), userID)
if err != nil {
s.loggerFor(r.Context()).Error("preference read failed", "user", userID, "error", err)
writeError(w, http.StatusInternalServerError, "could not read these settings")
return
}
// One account read for both the person's name and their televisions. A failure costs
// the page its device names, never the history itself — which is the half that cannot
// be reconstructed from anywhere else.
account, err := s.membyAccount(r, userID)
if err != nil {
s.loggerFor(r.Context()).Warn("account unavailable for settings history", "error", err)
}
writeJSON(w, http.StatusOK, map[string]any{
"userId": userID,
"username": account.Username,
"currentRevision": current.Revision,
"currentSource": current.Source,
"updatedAt": optionalTime(current.UpdatedAt),
"saved": current.Revision > 0,
"revisions": adminRevisions(history, current.Revision),
"devices": s.adminPreferenceDevices(r, userID, account.Devices, current.Revision),
"catalogue": preferenceCatalogue,
"schemaVersion": preferenceSchemaVersion,
})
}
// adminRevisions turns stored revisions into what the table draws. It is a pure function
// of the history so it can be tested without a database, which is the only way to pin the
// two rules that are easy to get wrong: the diff is against the revision *below* this one,
// and the oldest entry held has nothing below it to be a diff of.
func adminRevisions(history []store.PreferenceRevision, currentRevision int64) []adminPreferenceRevision {
result := make([]adminPreferenceRevision, 0, len(history))
for index, entry := range history {
document := decodePreferences(entry.Preferences)
// history is newest first, so the predecessor is the next element along.
initial := index == len(history)-1
changes := []preferenceChange{}
if !initial {
changes = preferenceChanges(decodePreferences(history[index+1].Preferences), document)
}
acks := entry.Acks
if acks == nil {
acks = []store.PreferenceAck{}
}
result = append(result, adminPreferenceRevision{
Revision: entry.Revision, Source: entry.Source,
Author: revisionAuthor(entry), DeviceID: entry.DeviceID,
ClientVersion: entry.ClientVersion, RestoredFrom: entry.RestoredFrom,
CreatedAt: entry.CreatedAt, Current: entry.Revision == currentRevision,
Changes: changes, Initial: initial, Preferences: document, Acks: acks,
})
}
return result
}
// revisionAuthor names who made a change in the console's own vocabulary. A device row
// with no name is an older write, from before the history recorded one — "a television" is
// honest where inventing a name would not be.
func revisionAuthor(entry store.PreferenceRevision) string {
switch {
case entry.Source == "admin":
return "Admin console"
case entry.DeviceName != "":
return entry.DeviceName
case entry.Source == "device":
return "A television"
default:
return entry.Source
}
}
// adminPreferenceDevices lists every television signed in to this account beside the
// revision it holds. Signed-in sets come first because they are the ones an operator can
// still expect to catch up; a set that has acked but is no longer signed in is kept so its
// last known position is not silently dropped.
func (s *Server) adminPreferenceDevices(
r *http.Request, userID string, known []store.MembyDevice, currentRevision int64,
) []adminPreferenceDevice {
states, err := s.store.PreferenceDeviceStates(r.Context(), userID)
if err != nil {
s.loggerFor(r.Context()).Warn("preference device states unavailable", "error", err)
states = map[string]store.PreferenceDeviceState{}
}
devices := []adminPreferenceDevice{}
seen := map[string]bool{}
for _, device := range known {
state, acked := states[device.ID]
seen[device.ID] = true
devices = append(devices, adminPreferenceDevice{
DeviceID: device.ID, Name: device.Name, ClientVersion: device.Version,
LastSeen: device.LastSeen, Revision: state.Revision,
AckedAt: optionalTime(state.AckedAt), Never: !acked,
Behind: behindBy(currentRevision, state.Revision),
})
}
for deviceID, state := range states {
if seen[deviceID] {
continue
}
devices = append(devices, adminPreferenceDevice{
DeviceID: deviceID, Name: "Signed-out television", SignedOut: true,
Revision: state.Revision, AckedAt: optionalTime(state.AckedAt),
Behind: behindBy(currentRevision, state.Revision),
})
}
sort.SliceStable(devices, func(i, j int) bool {
if devices[i].SignedOut != devices[j].SignedOut {
return devices[j].SignedOut
}
if devices[i].Behind != devices[j].Behind {
return devices[i].Behind > devices[j].Behind
}
return strings.ToLower(devices[i].Name) < strings.ToLower(devices[j].Name)
})
return devices
}
// behindBy never goes negative. A television holding a revision above the current one is
// impossible in ordinary operation, but the counter is the whole delivery mechanism and a
// negative "behind" on the page would read as nonsense rather than as the anomaly it is.
func behindBy(current, held int64) int64 {
if held >= current {
return 0
}
return current - held
}
func optionalTime(value time.Time) any {
if value.IsZero() {
return nil
}
return value
}
// membyAccount is one person out of the same list the console's other pages read. Somebody
// with no signed-in television is not an error: their settings history outlives their
// devices, and is one of the more useful times to be able to read it.
func (s *Server) membyAccount(r *http.Request, userID string) (store.MembyAccount, error) {
accounts, err := s.store.MembyAccounts(r.Context())
if err != nil {
return store.MembyAccount{}, err
}
for _, account := range accounts {
if account.ID == userID {
return account, nil
}
}
return store.MembyAccount{}, nil
}
// handleAdminRestorePreferences puts an earlier document back.
//
// It is a forward write, never a rewind: the restored document goes out as the next
// revision, attributed to the console and recording what it was taken from. That is what
// makes it visible to televisions at all — they compare revision numbers — and it is also
// what makes a restore itself undoable, since the revision it replaced is still sitting in
// the history one row down.
//
// The document is re-normalised on the way out. A revision written before a setting
// existed has nothing to say about it, and the catalogue's default is the right answer
// there; a revision written before a setting's options changed may hold one that is no
// longer legal, and restoring it verbatim would put a value on a television that the
// server itself would reject.
func (s *Server) handleAdminRestorePreferences(w http.ResponseWriter, r *http.Request) {
userID := strings.TrimSpace(r.PathValue("userID"))
revision, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("revision")), 10, 64)
if userID == "" || err != nil || revision <= 0 {
writeError(w, http.StatusBadRequest, "user and revision are required")
return
}
document, err := s.store.PreferenceRevisionDocument(r.Context(), userID, revision)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "that revision is no longer held")
return
}
if err != nil {
s.loggerFor(r.Context()).Error("preference revision read failed",
"user", userID, "revision", revision, "error", err)
writeError(w, http.StatusInternalServerError, "could not read that revision")
return
}
values := map[string]any{}
_ = json.Unmarshal(document, &values)
raw, err := json.Marshal(normalizePreferences(values))
if err != nil {
writeError(w, http.StatusInternalServerError, "could not rebuild that revision")
return
}
// ForceRevision for the same reason an ordinary push takes it: every one of this
// person's televisions is also a writer of this row, and an operator deliberately
// restoring a version must not lose a race to whichever set syncs next.
stored, err := s.store.SetUserPreferences(r.Context(), userID, store.PreferenceWrite{
Preferences: raw, ExpectedRevision: store.ForceRevision,
Source: "admin", RestoredFrom: revision,
})
if err != nil {
s.loggerFor(r.Context()).Error("preference restore failed",
"user", userID, "revision", revision, "error", err)
writeError(w, http.StatusInternalServerError, "could not restore that revision")
return
}
s.loggerFor(r.Context()).Info("settings restored",
"user", userID, "revision", stored.Revision, "restoredFrom", revision, "source", "admin")
writeJSON(w, http.StatusOK, preferencesPayload(stored))
}
@@ -0,0 +1,157 @@
package api
import (
"encoding/json"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
func documentJSON(t *testing.T, values map[string]any) []byte {
t.Helper()
raw, err := json.Marshal(normalizePreferences(values))
if err != nil {
t.Fatalf("marshal document: %v", err)
}
return raw
}
// The whole point of the history table: one line per setting, in the words the catalogue
// uses, rather than two JSON documents for somebody to compare by eye.
func TestPreferenceChangesDescribesOneSetting(t *testing.T) {
before := normalizePreferences(map[string]any{"homeCardDensity": "standard"})
after := normalizePreferences(map[string]any{"homeCardDensity": "large"})
changes := preferenceChanges(before, after)
if len(changes) != 1 {
t.Fatalf("got %d changes, want 1: %+v", len(changes), changes)
}
if changes[0].Key != "homeCardDensity" {
t.Errorf("key = %q, want homeCardDensity", changes[0].Key)
}
if changes[0].Before != "Standard" || changes[0].After != "Large" {
t.Errorf("got %q → %q, want Standard → Large", changes[0].Before, changes[0].After)
}
}
// A television pushing back the document it already held is an ordinary event. Listing it
// as a change would fill the history with rows nobody made.
func TestPreferenceChangesIgnoresIdenticalDocuments(t *testing.T) {
document := normalizePreferences(map[string]any{"subtitlesEnabled": false})
if changes := preferenceChanges(document, document); len(changes) != 0 {
t.Fatalf("got %d changes for an unchanged document: %+v", len(changes), changes)
}
}
// A key the catalogue no longer knows is not a change anybody can act on, and a value
// stored before a setting existed must not read as one appearing out of nothing.
func TestPreferenceChangesIgnoresUnknownKeys(t *testing.T) {
before := map[string]any{"retiredSetting": "one"}
after := map[string]any{"retiredSetting": "two"}
if changes := preferenceChanges(before, after); len(changes) != 0 {
t.Fatalf("an unknown key produced changes: %+v", changes)
}
}
// Order is the setting for a multi, so a reordered list is a change even though the same
// rows are ticked.
func TestPreferenceChangesNoticesReorderedRows(t *testing.T) {
before := normalizePreferences(map[string]any{
"homeSections": []any{"continue", "favorites", "latest"}})
after := normalizePreferences(map[string]any{
"homeSections": []any{"favorites", "continue", "latest"}})
changes := preferenceChanges(before, after)
if len(changes) != 1 || changes[0].Key != "homeSections" {
t.Fatalf("got %+v, want one homeSections change", changes)
}
}
func TestPreferenceValueLabelWordsEveryKind(t *testing.T) {
for _, testCase := range []struct {
key string
value any
want string
}{
{"showTitleLogo", true, "On"},
{"showTitleLogo", false, "Off"},
{"homeArtworkStyle", "backdrop", "Backdrops"},
{"seekIntervalSeconds", 30, "30 seconds"},
{"forYouMinutes", 0, "No limit"},
{"homeSections", []string{"latest", "continue"}, "Latest movies, Continue watching"},
{"homeHiddenRows", []string{}, "None"},
{"homeHiddenRows", []string{"recommended"}, "recommended"},
} {
definition, ok := preferenceDefinitionFor(testCase.key)
if !ok {
t.Fatalf("%s is not in the catalogue", testCase.key)
}
got := preferenceValueLabel(definition, normalizePreference(definition, testCase.value))
if got != testCase.want {
t.Errorf("%s = %q, want %q", testCase.key, got, testCase.want)
}
}
}
// The history arrives newest first, so a row's diff is against the element *after* it. Get
// this backwards and every line in the table reads inverted.
func TestAdminRevisionsDiffsAgainstThePrecedingRevision(t *testing.T) {
history := []store.PreferenceRevision{
{Revision: 3, Preferences: documentJSON(t, map[string]any{"homeCardDensity": "large"}),
Source: "admin", CreatedAt: time.Now()},
{Revision: 2, Preferences: documentJSON(t, map[string]any{"homeCardDensity": "compact"}),
Source: "device", DeviceName: "Lounge", CreatedAt: time.Now()},
{Revision: 1, Preferences: documentJSON(t, nil), Source: "device", CreatedAt: time.Now()},
}
rows := adminRevisions(history, 3)
if len(rows) != 3 {
t.Fatalf("got %d rows, want 3", len(rows))
}
if !rows[0].Current {
t.Error("the newest revision is not marked current")
}
if len(rows[0].Changes) != 1 || rows[0].Changes[0].Before != "Compact" ||
rows[0].Changes[0].After != "Large" {
t.Fatalf("r3 changes = %+v, want compact → large", rows[0].Changes)
}
// The oldest entry held has nothing below it, so it is labelled rather than diffed
// against the defaults — which would claim decisions nobody made.
if !rows[2].Initial || len(rows[2].Changes) != 0 {
t.Errorf("r1 initial = %v with %d changes, want true with 0",
rows[2].Initial, len(rows[2].Changes))
}
if rows[0].Author != "Admin console" || rows[1].Author != "Lounge" {
t.Errorf("authors = %q, %q; want Admin console, Lounge", rows[0].Author, rows[1].Author)
}
// A write from before the history recorded a device name must not be attributed to
// an invented one.
if rows[2].Author != "A television" {
t.Errorf("nameless device author = %q, want A television", rows[2].Author)
}
}
// A restore is announced as what it is, and the row it came from stays named.
func TestAdminRevisionsCarriesRestoreProvenance(t *testing.T) {
history := []store.PreferenceRevision{
{Revision: 5, Preferences: documentJSON(t, nil), Source: "admin", RestoredFrom: 2},
{Revision: 4, Preferences: documentJSON(t, nil), Source: "device"},
}
rows := adminRevisions(history, 5)
if rows[0].RestoredFrom != 2 {
t.Errorf("restoredFrom = %d, want 2", rows[0].RestoredFrom)
}
}
// "Behind" is what the page colours by, and a set holding something newer than the current
// revision is an anomaly rather than a negative number to render.
func TestBehindByNeverGoesNegative(t *testing.T) {
for _, testCase := range []struct{ current, held, want int64 }{
{7, 7, 0}, {7, 4, 3}, {7, 9, 0}, {7, 0, 7},
} {
if got := behindBy(testCase.current, testCase.held); got != testCase.want {
t.Errorf("behindBy(%d, %d) = %d, want %d",
testCase.current, testCase.held, got, testCase.want)
}
}
}
+171
View File
@@ -0,0 +1,171 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"testing"
"time"
)
// A local preview of the admin console with no gateway behind it.
//
// cd server && ADMIN_PREVIEW=1 go test ./internal/api -run TestAdminPreview -timeout 0
// → http://127.0.0.1:7777/admin/overview
//
// It serves the same finished bytes the real console serves — adminRendered, composed from
// the embedded shell, stylesheet and fragments — so what appears here is what a deployment
// would show. What it fakes is only the data: /admin/api/* answers from the canned status
// below, which is what makes it runnable without Postgres, Redis or an Emby to talk to.
//
// It is a test so that it can reach adminRendered, which is unexported for the good reason
// that composing the console is nobody else's business. It skips unless ADMIN_PREVIEW is
// set, so an ordinary `go test ./...` never blocks on a server that runs until interrupted.
func TestAdminPreview(t *testing.T) {
if os.Getenv("ADMIN_PREVIEW") == "" {
t.Skip("set ADMIN_PREVIEW=1 to serve the console locally")
}
address := os.Getenv("ADMIN_PREVIEW_ADDR")
if address == "" {
address = "127.0.0.1:7777"
}
mux := http.NewServeMux()
mux.HandleFunc("/admin/api/", func(w http.ResponseWriter, r *http.Request) {
body, ok := adminPreviewData()[strings.TrimSuffix(r.URL.Path, "/")]
if !ok {
body = map[string]any{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
})
mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/admin/")
if id == "" {
id = "overview"
}
// A hidden page is addressed by a route carrying something else in the path, so the
// preview takes the first segment and lets /admin/accounts/42 render the account page.
id, _, _ = strings.Cut(id, "/")
page, ok := adminRendered[id]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(page)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/overview", http.StatusFound)
})
fmt.Printf("\nadmin console preview → http://%s/admin/overview (ctrl-c to stop)\n\n", address)
if err := http.ListenAndServe(address, mux); err != nil {
t.Fatal(err)
}
}
// Enough of a household for every page to draw itself: a library, two viewers, two
// televisions, a run that succeeded and one that did not.
func adminPreviewData() map[string]any {
now := time.Now().UTC()
stamp := func(d time.Duration) string { return now.Add(-d).Format(time.RFC3339) }
// One set of each presence state, because the dot is three-valued and a preview that
// only ever showed green would not be a preview of it.
build := func(version string, age time.Duration) map[string]any {
return map[string]any{"version": version,
"firstSeen": stamp(age + 24*time.Hour), "lastSeen": stamp(age)}
}
clients := []any{
map[string]any{"deviceId": "tv-1", "deviceName": "Living room", "username": "matt",
"version": "0.1.74", "lastSeen": stamp(30 * time.Second),
"capabilities": []string{"server_features_v1", "install_permission_v1"},
"versions": []any{build("0.1.74", 30*time.Second), build("0.1.72", 9*24*time.Hour),
build("0.1.68", 40*24*time.Hour)}},
map[string]any{"deviceId": "tv-2", "deviceName": "Bedroom", "username": "sam",
"version": "0.1.71", "lastSeen": stamp(2 * 24 * time.Hour), "capabilities": []string{},
"versions": []any{build("0.1.71", 2*24*time.Hour)}},
map[string]any{"deviceId": "tv-3", "deviceName": "Spare room", "username": "sam",
"version": "0.1.62", "lastSeen": stamp(70 * 24 * time.Hour),
"capabilities": []string{"server_features_v1"},
"versions": []any{build("0.1.62", 70*24*time.Hour)}},
}
features := []any{
map[string]any{"key": "subtitle_download", "label": "Download subtitles",
"description": "Fetch a missing subtitle from the player.", "enabled": true, "source": "default"},
map[string]any{"key": "install_permission_prompt", "label": "Install permission step",
"description": "Show the sideloading permission screen during setup.", "enabled": true, "source": "override"},
map[string]any{"key": "media_requests", "label": "Request it",
"description": "Let viewers ask for a title the library does not have.", "enabled": false, "source": "default"},
}
status := map[string]any{
"serverVersion": "preview",
"library": map[string]any{"total": 18422, "lastSynced": stamp(42 * time.Minute),
"byType": map[string]any{"Movie": 4210, "Series": 612, "Episode": 13600}},
"requestUsers": []any{"matt", "sam", "alex", "ros"},
"clients": clients,
"features": map[string]any{"revision": 12, "safeMode": false, "features": features},
"maintenance": map[string]any{"enabled": false, "message": ""},
"updatePolicy": map[string]any{"enabled": true, "latestVersion": "0.1.74",
"minimumVersion": "", "downloadUrl": "https://example.invalid/memby.apk"},
"playbackPolicy": map[string]any{"prerollEnabled": true, "prerollDurationMs": 6500},
"syncRunning": false,
"syncEvery": "1h",
"radarrReady": true,
"sonarrReady": false,
"mdblist": map[string]any{"enabled": true, "cachedTitles": 3121, "staleTitles": 44,
"apiKeyConfigured": true, "sources": []any{
map[string]any{"key": "imdb", "label": "IMDb", "enabled": true},
map[string]any{"key": "tmdb", "label": "TMDb", "enabled": true},
map[string]any{"key": "tomatoes", "label": "Rotten Tomatoes", "enabled": false},
}},
"forYou": map[string]any{"candidates": 2400, "profiles": 5, "tracearrSessions": 812,
"lastFullImport": stamp(30 * time.Hour)},
"forYouRunning": false,
"runs": []any{
map[string]any{"startedAt": stamp(42 * time.Minute), "kind": "incremental",
"status": "success", "itemsUpserted": 24},
map[string]any{"startedAt": stamp(102 * time.Minute), "kind": "incremental",
"status": "running", "itemsUpserted": 0},
map[string]any{"startedAt": stamp(162 * time.Minute), "kind": "full",
"status": "failed", "itemsUpserted": 0, "error": "dial tcp: connection refused"},
},
}
return map[string]any{
"/admin/api/status": status,
"/admin/api/runtime": map[string]any{"goroutines": 84, "heapInuse": 41943040,
"sys": 92274688, "numGc": 311, "nextGc": 62914560, "gomaxprocs": 4},
"/admin/api/accounts": map[string]any{
"accounts": []any{
map[string]any{"userId": "u-1", "username": "matt", "onboardingCompleted": true,
"preferencesRevision": 12, "devices": []any{clients[0]}},
map[string]any{"userId": "u-2", "username": "sam jones", "onboardingCompleted": false,
"preferencesRevision": 3, "devices": []any{clients[1], clients[2]}},
},
"preferenceCatalogue": []any{},
},
"/admin/api/events": map[string]any{"next": 1, "hasMore": false, "dropped": 0, "events": []any{
map[string]any{"occurredAt": stamp(9 * time.Second), "level": "INFO", "message": "playback started",
"attributes": map[string]any{"component": "playback", "user": "matt", "device": "Living room",
"client": "0.1.74", "title": "Arrival"}},
map[string]any{"occurredAt": stamp(24 * time.Second), "level": "DEBUG", "message": "search",
"attributes": map[string]any{"component": "search", "user": "sam", "query": "arr"}},
map[string]any{"occurredAt": stamp(51 * time.Second), "level": "WARN", "message": "emby slow to answer",
"attributes": map[string]any{"component": "emby", "duration": "4.2s", "path": "/Items"}},
map[string]any{"occurredAt": stamp(2 * time.Minute), "level": "ERROR", "message": "library sync failed",
"attributes": map[string]any{"component": "library", "error": "dial tcp: connection refused"}},
}},
"/admin/api/engagement": map[string]any{"rows": []any{
map[string]any{"rowId": "continue", "title": "Continue watching", "impressions": 1840,
"focuses": 620, "selections": 210, "averageDwellMs": 2400},
map[string]any{"rowId": "favorites", "title": "Favourites", "impressions": 1610,
"focuses": 300, "selections": 74, "averageDwellMs": 1800},
}},
"/admin/api/requests": map[string]any{"requests": []any{}},
}
}
+275 -9
View File
@@ -1,6 +1,8 @@
package api
import (
"encoding/base64"
"encoding/binary"
"encoding/json"
"io"
"log/slog"
@@ -97,12 +99,159 @@ func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
rec = httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/", nil))
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/features" {
t.Fatalf("admin root should stay reachable via library redirect, got %d %q",
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/overview" {
t.Fatalf("admin root should stay reachable via overview redirect, got %d %q",
rec.Code, rec.Header().Get("Location"))
}
}
// installerSessionExpiring mints a session with a chosen life left, which is the only way
// to reach the renewal window without waiting a quarter of an hour in a test.
func installerSessionExpiring(t *testing.T, s *Server, remaining time.Duration) *http.Cookie {
t.Helper()
payload := make([]byte, 8+16)
binary.BigEndian.PutUint64(payload[:8], uint64(time.Now().Add(remaining).Unix()))
signature := s.signInstallerValue("session", payload)
return &http.Cookie{
Name: installerCookieName,
Value: base64.RawURLEncoding.EncodeToString(payload) + "." +
base64.RawURLEncoding.EncodeToString(signature),
}
}
func adminRequest(s *Server, remaining time.Duration, t *testing.T) *http.Request {
req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: s.cfg.AdminToken})
req.AddCookie(installerSessionExpiring(t, s, remaining))
return req
}
func renewedCookie(rec *httptest.ResponseRecorder) *http.Cookie {
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == installerCookieName {
return cookie
}
}
return nil
}
// The admin sign-in used to be an absolute half hour: an operator was signed out from
// under themselves mid-edit, and the console's poll then reported "invalid admin token"
// with no way back to a login.
func TestAdminSessionIsExtendedWhileTheOperatorIsWorking(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)
})
req := adminRequest(server, 2*time.Minute, t)
req.Header.Set(adminActivityHeader, "1")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("a session with two minutes left must still be accepted, got %d", rec.Code)
}
cookie := renewedCookie(rec)
if cookie == nil {
t.Fatal("expected a refreshed installer cookie")
}
follow := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil)
follow.AddCookie(cookie)
expires, ok := server.installerSessionExpiry(follow)
if !ok || time.Until(expires) < installerSessionTTL-time.Minute {
t.Fatalf("renewed session should carry a full TTL, has %v (ok=%v)",
time.Until(expires), ok)
}
}
// The other half of the rule: a tab left open on a second monitor polls by itself, so a
// session that renewed on any request at all would never expire.
func TestAdminSessionIsNotExtendedByThePollAlone(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)
})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, adminRequest(server, 2*time.Minute, t))
if rec.Code != http.StatusOK {
t.Fatalf("the poll itself should still be served, got %d", rec.Code)
}
if cookie := renewedCookie(rec); cookie != nil {
t.Fatal("an unattended poll must not extend the sign-in")
}
}
// Renewal rewrites a cookie, so it waits until there is something to gain. A working
// console makes a request every few seconds and must not re-issue on each one.
func TestAdminSessionIsNotRewrittenWhileItIsStillFresh(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)
})
req := adminRequest(server, installerSessionTTL-time.Minute, t)
req.Header.Set(adminActivityHeader, "1")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if cookie := renewedCookie(rec); cookie != nil {
t.Fatal("a fresh session should not be re-issued")
}
}
// A mutation is an operator by definition — nothing else sends one — so it needs no
// header to be believed.
func TestAdminMutationExtendsTheSessionWithoutTheActivityHeader(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)
})
req := httptest.NewRequest(http.MethodPost, "/admin/api/sync", nil)
req.AddCookie(&http.Cookie{Name: adminCookieName, Value: server.cfg.AdminToken})
req.AddCookie(installerSessionExpiring(t, server, 2*time.Minute))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if renewedCookie(rec) == nil {
t.Fatal("an operator action must extend the sign-in")
}
}
// Automation presents the token as a Bearer header and holds no session at all; renewal
// must not go looking for one.
func TestBearerAutomationIsUnaffectedByRenewal(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)
})
req := httptest.NewRequest(http.MethodPost, "/admin/api/deployment-alert", nil)
req.Header.Set("Authorization", "Bearer secret")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("bearer automation should be served, got %d", rec.Code)
}
if renewedCookie(rec) != nil {
t.Fatal("a tokened request should not be handed a session cookie")
}
}
func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) {
server := testServer(config.Config{})
server.maintenance.set(store.Maintenance{Enabled: true, Message: "Back after dinner"})
@@ -125,6 +274,58 @@ func TestServiceStatusReportsMaintenanceOutsideTheGate(t *testing.T) {
}
}
// The status poll is the app's only continuous channel, so everything that has to reach a
// television between requests rides on it. These two are the newest passengers, and both
// fail silently if a field is renamed: the outage bar simply never appears, and an
// operator's settings push is never collected.
func TestServiceStatusCarriesEmbyHealthAndPreferenceRevision(t *testing.T) {
server := testServer(config.Config{})
server.embyHealth.begin(60*time.Second, time.Now().UTC())
server.embyHealth.record(false, time.Now().UTC())
server.embyHealth.record(false, time.Now().UTC())
rec := httptest.NewRecorder()
server.handleServiceStatus(
rec, httptest.NewRequest(http.MethodGet, "/v1/status", nil), store.Session{})
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
health, ok := body["emby"].(map[string]any)
if !ok {
t.Fatalf("status response carried no emby health: %v", body)
}
if health["monitored"] != true || health["reachable"] != false {
t.Fatalf("emby health did not report the outage: %v", health)
}
if health["retrySeconds"] != float64(60) {
t.Fatalf("retrySeconds = %v, want 60 — the bar counts down from this", health["retrySeconds"])
}
// Present even with no store behind it: a client comparing against a missing field
// would never notice a push.
if _, ok := body["preferencesRevision"]; !ok {
t.Fatalf("status response carried no preferences revision: %v", body)
}
}
// With the probe switched off there is nothing to say, and a client must not be handed a
// value it could read as an outage.
func TestServiceStatusReportsEmbyHealthyWhenUnmonitored(t *testing.T) {
server := testServer(config.Config{})
rec := httptest.NewRecorder()
server.handleServiceStatus(
rec, httptest.NewRequest(http.MethodGet, "/v1/status", nil), store.Session{})
var body map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &body)
health := body["emby"].(map[string]any)
if health["monitored"] != false || health["reachable"] != true {
t.Fatalf("an unmonitored server was not reported healthy: %v", health)
}
}
func TestServiceStatusMakesProtocolMismatchVisible(t *testing.T) {
server := testServer(config.Config{})
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
@@ -141,7 +342,7 @@ func TestServiceStatusMakesProtocolMismatchVisible(t *testing.T) {
if body["compatible"] != false || body["compatibilityMessage"] == "" {
t.Fatalf("mismatch was not explicit: %v", body)
}
if body["clientVersion"] != "0.9.1" || body["serverProtocol"] != float64(membyProtocolVersion) {
if body["clientVersion"] != "0.9.1" || body["serverProtocol"] != float64(ProtocolVersion) {
t.Fatalf("version diagnostics missing: %v", body)
}
}
@@ -323,8 +524,9 @@ func TestAdminPagesUseRealRoutes(t *testing.T) {
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
for _, page := range []string{
"accounts", "library", "recommendations", "requests", "ratings", "maintenance",
"updates", "engagement", "imports", "logs",
"overview", "accounts", "clients", "library", "recommendations", "inspector",
"requests", "ratings", "features", "playback", "maintenance", "updates",
"engagement", "imports", "logs",
} {
req := httptest.NewRequest(http.MethodGet, "/admin/"+page, nil)
addInstallerSession(t, server, req)
@@ -359,14 +561,78 @@ func TestAccountsPageDistinguishesMembyFromEmbyAndProvidesManagement(t *testing.
server.adminRoutes().ServeHTTP(rec, req)
body := rec.Body.String()
if !strings.Contains(body, "This is the Memby user list, not the Emby user directory") {
t.Fatal("accounts page does not distinguish Memby users from Emby accounts")
}
// The directory lists people and links onwards. Everything an operator can *do* to
// somebody lives on that person's own page, so it must not be here.
for _, unwanted := range []string{"Remove Memby access", "Push to their televisions"} {
if strings.Contains(body, unwanted) {
t.Fatalf("accounts directory still carries the per-account editor: %q", unwanted)
}
}
}
func TestAccountPageCarriesTheManagementForOnePerson(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodGet, "/admin/accounts/user-7", nil)
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("/admin/accounts/user-7 = %d", rec.Code)
}
body := rec.Body.String()
for _, wanted := range []string{
"This is the Memby account list, not the Emby user directory",
"Signed-in devices", "Recommendation prompt", "Remove Memby access",
"Clear stored choices",
`data-admin-page="account"`, `id="account-devices"`, "Recommendation setup",
"Remove Memby access", "Clear stored choices", "Push to their televisions",
`href="/admin/accounts"`,
} {
if !strings.Contains(body, wanted) {
t.Fatalf("accounts page does not contain %q", wanted)
t.Fatalf("account page does not contain %q", wanted)
}
}
// Hidden pages are addressed by the route that carries the identity, never by name.
unnamed := httptest.NewRequest(http.MethodGet, "/admin/account", nil)
addInstallerSession(t, server, unnamed)
rec = httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, unnamed)
if rec.Code != http.StatusNotFound {
t.Fatalf("/admin/account = %d, want 404", rec.Code)
}
if cleanInstallerDestination("/admin/account") != "/install" {
t.Fatal("a sign-in may not return to an account page with nobody to be about")
}
}
// The console was one file holding every screen at once, all but one of them hidden. A page
// must now carry its own markup and nothing else: this is what stops that regressing.
func TestAdminPagesShipOnlyTheirOwnMarkup(t *testing.T) {
server := testServer(config.Config{
AdminToken: "secret", ReleasePublishToken: "release-secret",
})
req := httptest.NewRequest(http.MethodGet, "/admin/logs", nil)
addInstallerSession(t, server, req)
rec := httptest.NewRecorder()
server.adminRoutes().ServeHTTP(rec, req)
body := rec.Body.String()
if !strings.Contains(body, `id="log"`) {
t.Fatal("logs page does not contain the log viewer")
}
for _, foreign := range []string{
`id="mdblist-sources"`, `id="feature-list"`, `id="account-settings"`,
`id="inspector-results"`, `id="update-version"`,
} {
if strings.Contains(body, foreign) {
t.Fatalf("logs page still ships another page's markup: %q", foreign)
}
}
}
+4 -3
View File
@@ -16,6 +16,7 @@ const (
alertKindLibrarySync = "library-updated"
alertKindServerDown = "server-unreachable"
alertKindServerUp = "server-restored"
alertKindDeploying = "server-deploying"
// A TV shows one banner at a time; more than a few queued up is noise, not news.
maxAlerts = 3
@@ -108,13 +109,13 @@ func (s *Server) publishAlert(ctx context.Context, alert clientAlert, window tim
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)
s.loggerFor(ctx).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)
s.loggerFor(ctx).Warn("alert store failed", "error", err)
}
}
@@ -184,7 +185,7 @@ func (s *Server) sonarrAiredAlerts(ctx context.Context) []clientAlert {
}
row, err := s.sonarrAiringTodayRow(ctx)
if err != nil {
s.log.Warn("sonarr alerts unavailable", "error", err)
s.loggerFor(ctx).Warn("sonarr alerts unavailable", "error", err)
return nil
}
if row == nil {
+1 -1
View File
@@ -56,7 +56,7 @@ func (s *Server) handleRowAnalytics(w http.ResponseWriter, r *http.Request, sess
}
if err := s.store.InsertRowEvents(r.Context(), events); err != nil {
s.log.Warn("row analytics write failed", "error", err)
s.loggerFor(r.Context()).Warn("row analytics write failed", "error", err)
// Still a 204: telemetry must never make the TV think something is broken.
}
for _, event := range events {
+62 -5
View File
@@ -22,6 +22,7 @@ import (
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/bazarr"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
@@ -43,21 +44,35 @@ type Server struct {
forYou *foryou.Service
sonarr *sonarr.Client
radarr *radarr.Client
bazarr *bazarr.Client
mdblist *mdblist.Client
syncer syncerHandle
log *slog.Logger
events *serverlogging.Buffer
sonarrMu sync.Mutex
radarrMu sync.Mutex
bazarrMu sync.Mutex
mdblistMu sync.Mutex
// mdblistSettingsCache spares every row and keystroke a settings read.
mdblistSettingsCache mdblistSettingsCache
// ratingsWarm fills and renews the durable rating cache behind the viewer, so a row
// never waits on MDBList and the operator's daily allowance is spent once per title.
ratingsWarm ratingsWarmer
// 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
// playbackTitles lets a progress or stop report, which carries only an item id, be
// logged by name.
playbackTitles playbackTitles
recommendationBuilds recommendationBuilds
maintenance maintenanceState
updatePolicy updatePolicyCache
// embyHealth is the reachability probe's live finding, which /v1/status publishes so
// a TV can show why playback stopped even if it missed the announcement.
embyHealth embyHealth
}
// Deps are the collaborators the API needs. A struct rather than positional arguments:
@@ -70,6 +85,7 @@ type Deps struct {
ForYou *foryou.Service
Sonarr *sonarr.Client
Radarr *radarr.Client
Bazarr *bazarr.Client
MDBList *mdblist.Client
Syncer syncerHandle
Log *slog.Logger
@@ -86,6 +102,7 @@ func New(cfg config.Config, deps Deps) *Server {
forYou: deps.ForYou,
sonarr: deps.Sonarr,
radarr: deps.Radarr,
bazarr: deps.Bazarr,
mdblist: deps.MDBList,
syncer: deps.Syncer,
log: deps.Log,
@@ -126,6 +143,10 @@ func (s *Server) Routes() http.Handler {
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))
// A viewer's settings follow the person, not the television. Both verbs land on one
// handler because a write answers with the stored document, not the submitted one.
v1.Handle("GET /v1/preferences", s.authed(s.handlePreferences))
v1.Handle("PUT /v1/preferences", s.authed(s.handlePreferences))
v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem))
v1.Handle("GET /v1/items/{id}/ratings", s.authed(s.handleMovieRatings))
@@ -136,6 +157,8 @@ func (s *Server) Routes() http.Handler {
v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed))
v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback))
v1.Handle("GET /v1/items/{id}/next", s.authed(s.handleNextEpisode))
v1.Handle("GET /v1/items/{id}/subtitles/search", s.authed(s.handleSubtitleSearch))
v1.Handle("POST /v1/items/{id}/subtitles/download", s.authed(s.handleSubtitleDownload))
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
@@ -196,6 +219,7 @@ func (s *Server) authed(h authedFunc) http.Handler {
return
}
sess = s.captureClientIdentity(r, sess)
identify(r.Context(), sess)
h(w, r, sess)
})
}
@@ -204,6 +228,7 @@ func (s *Server) authed(h authedFunc) http.Handler {
// calls refresh it from headers; authenticated artwork requests, which can only carry a
// query token, inherit the last identity reported by that same TV.
func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) store.Session {
previousVersion := sess.ClientVersion
changed := mergeClientIdentity(r, &sess)
if changed {
if err := s.store.UpdateSessionClientIdentity(
@@ -214,6 +239,18 @@ func (s *Server) captureClientIdentity(r *http.Request, sess store.Session) stor
} else {
s.cacheSession(r.Context(), sess)
}
// A television that updates itself never signs in again, so this is the only
// place the new build would otherwise be seen. Guarded on the version actually
// having moved: every request reaches here, and all but the first after an
// update would be a write of what is already stored.
if sess.ClientVersion != previousVersion {
if err := s.store.RecordDeviceVersion(
r.Context(), sess.DeviceID, sess.ClientVersion,
); err != nil {
s.log.Warn("device version record failed",
"device_id", sess.DeviceID, "error", err)
}
}
}
return sess
}
@@ -247,6 +284,7 @@ func mergeClientIdentity(r *http.Request, sess *store.Session) bool {
func (s *Server) withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
r, identity := withRequestIdentity(r)
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
// Polling the live-log endpoint must not create another live-log record and
@@ -254,16 +292,31 @@ func (s *Server) withLogging(next http.Handler) http.Handler {
if r.URL.Path == "/admin/api/events" {
return
}
// The request line is a transcript of one exchange, not the record of what the
// viewer did — that is what the events the handlers log are for. It stays terse
// and identical in shape for every route so it can be scanned in a column.
//
// Path only: query strings can carry image tokens.
level := requestLogLevel(r.URL.Path, rec.status)
s.log.Log(r.Context(), level, "HTTP request",
fields := []any{"component", identity.component}
fields = append(fields, identity.viewerAttrs()...)
// The app build keeps its placeholder where the viewer does not, because "which
// build made this call" always has an answer worth seeing, including "it did
// not say".
fields = append(fields,
"client", clientLogValue(identity.client),
"protocol", clientLogValue(identity.protocol),
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start).Round(time.Millisecond),
"client_version", clientLogValue(clientVersion(r)),
"client_protocol", clientLogValue(clientProtocol(r)),
)
// Whether an answer came from cache is the first thing anyone asks of a slow
// screen, and only the handler knows.
if cached := rec.Header().Get("X-Memby-Cache"); cached != "" {
fields = append(fields, "cache", cached)
}
s.log.Log(r.Context(), level, "request", fields...)
})
}
@@ -284,6 +337,7 @@ func requestLogLevel(path string, status int) slog.Level {
case status >= http.StatusBadRequest:
return slog.LevelWarn
case path == "/healthz", path == "/readyz", path == "/v1/status",
path == "/admin/api/status",
strings.HasPrefix(path, "/v1/images/"):
return slog.LevelDebug
default:
@@ -400,6 +454,7 @@ func credentials(sess store.Session) emby.Credentials {
return emby.Credentials{
UserID: sess.EmbyUserID, Token: sess.EmbyToken,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
ClientVersion: sess.ClientVersion,
}
}
@@ -426,7 +481,9 @@ func writeError(w http.ResponseWriter, status int, message string) {
// writeUpstreamError mirrors Emby's status so the TV can tell "signed out" (401) from
// "server is unwell" (5xx) without parsing strings.
func (s *Server) writeUpstreamError(w http.ResponseWriter, err error, message string) {
func (s *Server) writeUpstreamError(
ctx context.Context, w http.ResponseWriter, err error, message string,
) {
var apiErr *emby.APIError
if errors.As(err, &apiErr) {
switch {
@@ -438,7 +495,7 @@ func (s *Server) writeUpstreamError(w http.ResponseWriter, err error, message st
return
}
}
s.log.Error(message, "error", err)
s.loggerFor(ctx).Error(message, "error", err)
writeError(w, http.StatusBadGateway, message)
}
+38 -6
View File
@@ -95,6 +95,38 @@ func TestRecommendationOnboardingCandidatesMixMoviesAndSeries(t *testing.T) {
}
}
func TestRecommendationOnboardingPeopleSeparatesPerformersAndDirectors(t *testing.T) {
items := []recommend.Item{{
ID: "arrival", Type: "Movie", CommunityRating: 8.2,
People: []recommend.Person{
{ID: "amy", Name: "Amy Adams", Type: "Actor", PrimaryImageTag: "amy-tag"},
{ID: "jeremy", Name: "Jeremy Renner", Type: "Actor"},
{ID: "denis", Name: "Denis Villeneuve", Type: "Director"},
},
}}
actors, actresses, directors := recommendationOnboardingPeople(items, 10)
if len(actors) != 1 || actors[0].Name != "Jeremy Renner" {
t.Fatalf("actors = %#v", actors)
}
if len(actresses) != 1 || actresses[0].Name != "Amy Adams" || actresses[0].ImageTag != "amy-tag" {
t.Fatalf("actresses = %#v", actresses)
}
if len(directors) != 1 || directors[0].Name != "Denis Villeneuve" {
t.Fatalf("directors = %#v", directors)
}
}
func TestRecommendationOnboardingResponseIncludesServerPromptState(t *testing.T) {
raw, err := json.Marshal(recommendationOnboardingResponse{Prompted: true})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"prompted":true`) {
t.Fatalf("response = %s", raw)
}
}
func TestEpisodeCodeFormatsPlaybackMetadata(t *testing.T) {
item := emby.Summary{Type: "Episode", ParentIndexNumber: 2, IndexNumber: 4}
if got := episodeCode(item); got != "S02E04" {
@@ -253,11 +285,11 @@ func TestBaseRowsShape(t *testing.T) {
Favorites: []json.RawMessage{json.RawMessage(`{"Id":"2"}`)},
})
if len(rows) != 4 {
t.Fatalf("expected 4 base rows, got %d", len(rows))
if len(rows) != 3 {
t.Fatalf("expected 3 base rows, got %d", len(rows))
}
wantIDs := []string{"continue", "next-up", "favorites", "latest-movies"}
wantKinds := []string{"continue", "nextup", "favorites", "latest"}
wantIDs := []string{"continue", "favorites", "latest-movies"}
wantKinds := []string{"continue", "favorites", "latest"}
for i, row := range rows {
if row.ID != wantIDs[i] {
t.Fatalf("row %d id = %q, want %q", i, row.ID, wantIDs[i])
@@ -271,8 +303,8 @@ func TestBaseRowsShape(t *testing.T) {
}
// The favourites row carries the items the client used to assemble itself.
if len(rows[2].Items) != 1 {
t.Fatalf("favourites row lost its items: %+v", rows[2])
if len(rows[1].Items) != 1 {
t.Fatalf("favourites row lost its items: %+v", rows[1])
}
}
+85 -6
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"net/http"
"strings"
@@ -60,7 +61,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
// Compatibility for APKs released before device naming. New clients require an
// editable name in their UI, but an older TV must still be able to sign in while
// the household rollout is in progress.
req.DeviceName = "Memby TV"
req.DeviceName = store.DefaultDeviceName
}
if len([]rune(req.DeviceName)) > 80 {
writeError(w, http.StatusBadRequest, "device name is too long")
@@ -68,12 +69,15 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
}
auth, err := s.emby.Authenticate(
r.Context(), req.Username, req.Password, req.DeviceID, req.DeviceName,
r.Context(), req.Username, req.Password, req.DeviceID, req.DeviceName, clientVersion(r),
)
if err != nil {
// Never echo Emby's body here: a failed sign-in is the one place a wrong
// password could be reflected back.
s.log.Warn("emby authentication failed", "username", req.Username)
s.loggerFor(r.Context()).Warn("sign-in rejected",
"username", req.Username, "device", req.DeviceName, "device_id", req.DeviceID,
"reason", "emby refused the credentials",
)
writeError(w, http.StatusUnauthorized, "sign-in failed")
return
}
@@ -100,24 +104,41 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if sess.Username == "" {
sess.Username = req.Username
}
replacedHash, err := s.store.CreateSession(r.Context(), sess)
created, err := s.store.CreateSession(r.Context(), sess)
if err != nil {
_ = s.emby.Logout(r.Context(), emby.Credentials{
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: req.DeviceID, DeviceName: req.DeviceName,
ClientVersion: sess.ClientVersion,
})
s.log.Error("session persist failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start a session")
return
}
if len(replacedHash) > 0 {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(replacedHash)))
if len(created.ReplacedHash) > 0 {
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(created.ReplacedHash)))
}
s.retireSupersededDevices(r.Context(), created.Superseded)
// Recorded after the session exists, so a build history can only describe a
// television that got as far as signing in.
if err := s.store.RecordDeviceVersion(r.Context(), sess.DeviceID, sess.ClientVersion); err != nil {
s.loggerFor(r.Context()).Warn("device version record failed",
"device_id", sess.DeviceID, "error", err)
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, false)
}
// Now that the session exists, the request line this call ends with can name it too.
identify(r.Context(), sess)
s.loggerFor(r.Context()).Info("signed in",
"emby_user", sess.EmbyUserID,
"device_id", sess.DeviceID,
"protocol", clientLogValue(sess.ClientProtocol),
"replaced_session", len(created.ReplacedHash) > 0,
)
writeJSON(w, http.StatusOK, loginResponse{
Token: token, UserID: sess.EmbyUserID, Username: sess.Username, ServerID: sess.ServerID,
})
@@ -129,6 +150,7 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store
}
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash)))
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
s.loggerFor(r.Context()).Info("signed out", "device_id", sess.DeviceID)
w.WriteHeader(http.StatusNoContent)
}
@@ -178,9 +200,63 @@ func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request, curr
return
}
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(tokenHash)))
s.retireEmbyDevice(r.Context(), deviceID)
if err := s.store.DeleteDeviceVersions(r.Context(), deviceID); err != nil {
s.loggerFor(r.Context()).Warn("device version cleanup failed",
"removed_device_id", deviceID, "error", err)
}
// A device disappearing from a household is worth a line: the next thing that TV
// reports is a sign-in, and the two together explain each other.
s.loggerFor(r.Context()).Info("device signed out remotely", "removed_device_id", deviceID)
w.WriteHeader(http.StatusNoContent)
}
// retireSupersededDevices finishes what CreateSession started: the rows for a television
// under a device id it no longer uses are already gone from Postgres, and this takes the
// rest of that identity with them — the cached session, the build history and the record
// Emby is still holding in its own devices list.
//
// Best-effort throughout, and deliberately after the sign-in has succeeded: tidying up a
// set's previous life must never be what stops it getting in.
func (s *Server) retireSupersededDevices(ctx context.Context, devices []store.SupersededDevice) {
if len(devices) == 0 {
return
}
ids := make([]string, 0, len(devices))
for _, device := range devices {
ids = append(ids, device.DeviceID)
if s.cache != nil && len(device.TokenHash) > 0 {
_ = s.cache.Delete(ctx, cache.SessionKey(hexHash(device.TokenHash)))
}
s.retireEmbyDevice(ctx, device.DeviceID)
}
if err := s.store.DeleteDeviceVersions(ctx, ids...); err != nil {
s.loggerFor(ctx).Warn("device version cleanup failed", "error", err)
}
s.loggerFor(ctx).Info("device identity superseded", "retired_device_ids", ids)
}
// retireEmbyDevice deletes the Emby device record a removed television left behind.
//
// Revoking the gateway session only takes the TV out of Settings → Devices; Emby keeps
// its own record until the record itself is deleted, so without this a set removed from
// one list stays visible in the other. Deliberately best-effort: the session is already
// gone, which is what actually ends that TV's access, and a lingering Emby row is not
// worth failing the request the operator made. It needs the sync credentials because a
// device record belongs to Emby's server, not to the viewer whose session was removed.
func (s *Server) retireEmbyDevice(ctx context.Context, deviceID string) {
if s.emby == nil || s.cfg.SyncAPIKey == "" || deviceID == "" {
return
}
if err := s.emby.DeleteDevice(ctx, emby.Credentials{
UserID: s.cfg.SyncUserID, Token: s.cfg.SyncAPIKey,
DeviceID: "memby-gateway", DeviceName: "Memby Gateway",
}, deviceID); err != nil {
s.loggerFor(ctx).Warn("emby device cleanup failed",
"removed_device_id", deviceID, "error", err)
}
}
func (s *Server) handleRenameDevice(w http.ResponseWriter, r *http.Request, current store.Session) {
deviceID := strings.TrimSpace(r.PathValue("deviceID"))
var req renameDeviceRequest
@@ -205,5 +281,8 @@ func (s *Server) handleRenameDevice(w http.ResponseWriter, r *http.Request, curr
writeError(w, http.StatusInternalServerError, "could not rename device")
return
}
s.loggerFor(r.Context()).Info("device renamed",
"renamed_device_id", deviceID, "new_name", req.DeviceName,
)
w.WriteHeader(http.StatusNoContent)
}
+205
View File
@@ -0,0 +1,205 @@
package api
import (
"context"
"encoding/json"
"net/url"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
)
// Continue Watching and Next Up answer the same question — "what am I in the middle
// of?" — and splitting them across two rows meant a show moved between them the moment
// an episode ended. Finishing something is exactly when a viewer is most likely to want
// the next one, and that was the moment it left the first row on the launcher for one
// further down it. They are one row now.
//
// Merging them is not concatenation, because both lists are ordered by recency and the
// interesting case is a series that has just moved from one to the other. The order has
// to be "most recently watched first" across both, which needs a time for each card:
//
// - A resume item carries its own UserData.LastPlayedDate.
// - A Next Up episode carries nothing — it is unwatched, so its play date is zero. What
// places it is when its *series* was last watched, which is what
// recentlyPlayedSeries goes and asks for.
const (
// How far back to look for the play that puts a Next Up episode in order. This is a
// household's recent viewing, not its history: a series nobody has touched in this
// many plays is not competing for the front of the row anyway.
continuePlayLookback = 120
)
// recentlyPlayedSeries maps series id to the last time anything in it was played.
//
// It is one narrow, image-free list request beside the four the launcher already makes.
// Emby returns it most-recently-played first, so the first entry seen for a series is
// the one that counts.
func (s *Server) recentlyPlayedSeries(
ctx context.Context,
cred emby.Credentials,
) (map[string]time.Time, error) {
result, err := s.emby.Items(ctx, cred, url.Values{
"IncludeItemTypes": {"Episode"},
"Recursive": {"true"},
"Filters": {"IsPlayed"},
"SortBy": {"DatePlayed"},
"SortOrder": {"Descending"},
"Limit": {itoa(continuePlayLookback)},
"Fields": {"SeriesId"},
"EnableImages": {"false"},
"EnableUserData": {"true"},
"EnableTotalRecordCount": {"false"},
})
if err != nil {
return nil, err
}
played := make(map[string]time.Time, len(result.Items))
for _, raw := range result.Items {
seriesID, at, ok := seriesPlayedAt(raw)
if !ok {
continue
}
if _, seen := played[seriesID]; !seen {
played[seriesID] = at
}
}
return played, nil
}
type continueItem struct {
raw json.RawMessage
at time.Time
dated bool
}
// mergeContinueWatching interleaves the resume list and the Next Up list into one row,
// most recently watched first.
//
// Two properties are load-bearing and pinned by tests. Each source keeps its own
// relative order — Emby's ordering within a list is the useful part and this must never
// re-rank it — so this is a merge of two sorted lists, never a sort of their union. And
// a card with no usable date never displaces one that has: an unknown time falls back to
// the resume list first, which is the half that is definitely in progress.
//
// A series present in both is represented once, by its resume item: somebody who is
// eleven minutes into an episode wants that episode, not the one after it.
func mergeContinueWatching(
resume []json.RawMessage,
nextUp []json.RawMessage,
seriesPlayed map[string]time.Time,
) []json.RawMessage {
seenItems := make(map[string]bool, len(resume))
seenSeries := make(map[string]bool, len(resume))
inProgress := make([]continueItem, 0, len(resume))
for _, raw := range resume {
id, seriesID, at, dated := continueItemFields(raw, seriesPlayed)
if id != "" {
seenItems[id] = true
}
if seriesID != "" {
seenSeries[seriesID] = true
}
inProgress = append(inProgress, continueItem{raw: raw, at: at, dated: dated})
}
upNext := make([]continueItem, 0, len(nextUp))
for _, raw := range nextUp {
id, seriesID, at, dated := continueItemFields(raw, seriesPlayed)
if (id != "" && seenItems[id]) || (seriesID != "" && seenSeries[seriesID]) {
continue
}
if id != "" {
seenItems[id] = true
}
if seriesID != "" {
seenSeries[seriesID] = true
}
upNext = append(upNext, continueItem{raw: raw, at: at, dated: dated})
}
merged := make([]json.RawMessage, 0, len(inProgress)+len(upNext))
left, right := 0, 0
for left < len(inProgress) && right < len(upNext) {
next := upNext[right]
current := inProgress[left]
if next.dated && (!current.dated || next.at.After(current.at)) {
merged = append(merged, next.raw)
right++
continue
}
merged = append(merged, current.raw)
left++
}
for ; left < len(inProgress); left++ {
merged = append(merged, inProgress[left].raw)
}
for ; right < len(upNext); right++ {
merged = append(merged, upNext[right].raw)
}
return merged
}
// continueItemFields reads the identity and the time that places one card. An episode is
// timed by its own play date where it has one and by its series' otherwise, which is what
// puts a freshly unlocked Next Up episode ahead of a resume from last week.
func continueItemFields(
raw json.RawMessage,
seriesPlayed map[string]time.Time,
) (id string, seriesID string, at time.Time, dated bool) {
var item struct {
ID string `json:"Id"`
SeriesID string `json:"SeriesId"`
UserData struct {
LastPlayedDate string `json:"LastPlayedDate"`
} `json:"UserData"`
}
if json.Unmarshal(raw, &item) != nil {
return "", "", time.Time{}, false
}
if parsed, ok := parsePlayedAt(item.UserData.LastPlayedDate); ok {
return item.ID, item.SeriesID, parsed, true
}
if item.SeriesID != "" {
if parsed, ok := seriesPlayed[item.SeriesID]; ok {
return item.ID, item.SeriesID, parsed, true
}
}
return item.ID, item.SeriesID, time.Time{}, false
}
func seriesPlayedAt(raw json.RawMessage) (string, time.Time, bool) {
var item struct {
SeriesID string `json:"SeriesId"`
UserData struct {
LastPlayedDate string `json:"LastPlayedDate"`
} `json:"UserData"`
}
if json.Unmarshal(raw, &item) != nil || item.SeriesID == "" {
return "", time.Time{}, false
}
at, ok := parsePlayedAt(item.UserData.LastPlayedDate)
if !ok {
return "", time.Time{}, false
}
return item.SeriesID, at, true
}
// parsePlayedAt reads Emby's play timestamps. It writes .NET-style seven-digit fractional
// seconds, which RFC 3339 parsing handles, but older records and some plugins write a
// bare "yyyy-MM-ddTHH:mm:ssZ" — an unparseable date must leave the card undated rather
// than dropping it out of the row.
func parsePlayedAt(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
// Emby's "never played" sentinel. A real date it is not, and treating it as one would
// order a card by a year nobody watched anything in.
if value == "" || strings.HasPrefix(value, "0001-01-01") {
return time.Time{}, false
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05"} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
@@ -0,0 +1,156 @@
package api
import (
"encoding/json"
"testing"
"time"
)
func continueRaw(t *testing.T, fields map[string]any) json.RawMessage {
t.Helper()
raw, err := json.Marshal(fields)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return raw
}
func resumeItem(t *testing.T, id, seriesID, playedAt string) json.RawMessage {
t.Helper()
return continueRaw(t, map[string]any{
"Id": id, "SeriesId": seriesID, "Type": "Episode",
"UserData": map[string]any{"LastPlayedDate": playedAt},
})
}
func upNextItem(t *testing.T, id, seriesID string) json.RawMessage {
t.Helper()
return continueRaw(t, map[string]any{
"Id": id, "SeriesId": seriesID, "Type": "Episode",
"UserData": map[string]any{},
})
}
func mergedIDs(t *testing.T, items []json.RawMessage) []string {
t.Helper()
ids := make([]string, 0, len(items))
for _, raw := range items {
var item struct {
ID string `json:"Id"`
}
if err := json.Unmarshal(raw, &item); err != nil {
t.Fatalf("unmarshal: %v", err)
}
ids = append(ids, item.ID)
}
return ids
}
func equalIDs(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}
// The case the merged row exists for: an episode was just finished, so it has left the
// resume list, and the next one must be the first card rather than sitting in a second
// row further down the launcher.
func TestMergeContinueWatchingLeadsWithTheJustFinishedShow(t *testing.T) {
resume := []json.RawMessage{
resumeItem(t, "film", "", "2026-08-01T20:00:00.0000000Z"),
}
nextUp := []json.RawMessage{upNextItem(t, "s2e5", "severance")}
played := map[string]time.Time{
"severance": time.Date(2026, time.August, 6, 21, 0, 0, 0, time.UTC),
}
got := mergedIDs(t, mergeContinueWatching(resume, nextUp, played))
if want := []string{"s2e5", "film"}; !equalIDs(got, want) {
t.Fatalf("merged = %v, want %v", got, want)
}
}
// A show being watched right now must not appear twice — once part-way through an
// episode and once as the episode after it.
func TestMergeContinueWatchingPrefersTheResumeEpisodeOfASeries(t *testing.T) {
resume := []json.RawMessage{resumeItem(t, "s1e3", "show", "2026-08-06T19:00:00Z")}
nextUp := []json.RawMessage{
upNextItem(t, "s1e4", "show"),
upNextItem(t, "other-e1", "other"),
}
played := map[string]time.Time{
"show": time.Date(2026, time.August, 6, 19, 0, 0, 0, time.UTC),
"other": time.Date(2026, time.August, 2, 19, 0, 0, 0, time.UTC),
}
got := mergedIDs(t, mergeContinueWatching(resume, nextUp, played))
if want := []string{"s1e3", "other-e1"}; !equalIDs(got, want) {
t.Fatalf("merged = %v, want %v", got, want)
}
}
// Each source keeps its own order. Emby's ordering within a list is the useful part of
// both, so this must be a merge of two sorted lists and never a sort of their union.
func TestMergeContinueWatchingKeepsEachSourceOrder(t *testing.T) {
resume := []json.RawMessage{
resumeItem(t, "r1", "", "2026-08-06T10:00:00Z"),
resumeItem(t, "r2", "", "2026-08-04T10:00:00Z"),
resumeItem(t, "r3", "", "2026-08-01T10:00:00Z"),
}
nextUp := []json.RawMessage{
upNextItem(t, "n1", "alpha"),
upNextItem(t, "n2", "beta"),
}
played := map[string]time.Time{
"alpha": time.Date(2026, time.August, 5, 10, 0, 0, 0, time.UTC),
"beta": time.Date(2026, time.August, 3, 10, 0, 0, 0, time.UTC),
}
got := mergedIDs(t, mergeContinueWatching(resume, nextUp, played))
if want := []string{"r1", "n1", "r2", "n2", "r3"}; !equalIDs(got, want) {
t.Fatalf("merged = %v, want %v", got, want)
}
}
// With no play dates to go on — the lookup failed, or Emby wrote something unparseable —
// the row is the resume list followed by Next Up, which is the order the launcher had
// when they were two rows. Nothing is dropped.
func TestMergeContinueWatchingFallsBackToResumeFirst(t *testing.T) {
resume := []json.RawMessage{
resumeItem(t, "r1", "", "not a date"),
resumeItem(t, "r2", "", ""),
}
nextUp := []json.RawMessage{
upNextItem(t, "n1", "alpha"),
upNextItem(t, "n2", "beta"),
}
got := mergedIDs(t, mergeContinueWatching(resume, nextUp, nil))
if want := []string{"r1", "r2", "n1", "n2"}; !equalIDs(got, want) {
t.Fatalf("merged = %v, want %v", got, want)
}
}
func TestParsePlayedAtAcceptsEmbyTimestamps(t *testing.T) {
for _, value := range []string{
"2026-08-06T21:04:05.1234567Z",
"2026-08-06T21:04:05Z",
"2026-08-06T21:04:05+12:00",
"2026-08-06T21:04:05",
} {
if _, ok := parsePlayedAt(value); !ok {
t.Errorf("parsePlayedAt(%q) failed", value)
}
}
for _, value := range []string{"", " ", "0001-01-01", "yesterday"} {
if _, ok := parsePlayedAt(value); ok {
t.Errorf("parsePlayedAt(%q) unexpectedly succeeded", value)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
package api
import (
"sync"
"time"
)
// The live answer to "is Emby answering right now", as opposed to the one-shot alerts in
// server_alerts.go that announce the moment it changed.
//
// These are two different jobs and both are worth having. An alert is news: it slides in,
// says Emby stopped communicating and goes away, which is right for someone already
// watching. This is *state*: a television switched on twenty minutes into an outage was
// never told anything, and the one thing it can usefully show is a bar saying so and how
// long until the next attempt. It is the reason /v1/status carries it rather than the
// alert list — an alert that has fallen out of its window is gone, and this has not.
const (
// How many probes in a row must fail before the bar appears.
//
// Deliberately lower than embyFailureThreshold, which gates the announcement. The
// announcement is a one-shot that cannot be taken back, so it waits to be sure; the
// bar clears itself the moment Emby answers again, so being early costs a minute of
// a red strip rather than a false claim left standing.
embyOutageThreshold = 2
)
// embyHealth is the probe's finding, cached in memory for the status poll to read. Not
// persisted: it describes this instant, and a value restored from a restart would be a
// claim about a probe that never ran.
type embyHealth struct {
mu sync.RWMutex
state embyHealthState
}
type embyHealthState struct {
// monitored is false when MEMBY_EMBY_HEALTH_INTERVAL turns the probe off. The client
// must then ignore reachable entirely rather than trust a value nothing updates.
monitored bool
reachable bool
since time.Time
checkedAt time.Time
retryEvery time.Duration
consecutive int
}
func (h *embyHealth) get() embyHealthState {
h.mu.RLock()
defer h.mu.RUnlock()
return h.state
}
// begin records that the probe is running and how often, so the bar can say when the next
// attempt is due before any probe has completed.
func (h *embyHealth) begin(interval time.Duration, now time.Time) {
h.mu.Lock()
defer h.mu.Unlock()
h.state = embyHealthState{
monitored: true, reachable: true, since: now, retryEvery: interval,
}
}
// record folds one probe result in and reports whether the published verdict changed.
func (h *embyHealth) record(ok bool, now time.Time) {
h.mu.Lock()
defer h.mu.Unlock()
h.state.checkedAt = now
if ok {
h.state.consecutive = 0
if !h.state.reachable {
h.state.reachable = true
h.state.since = now
}
return
}
h.state.consecutive++
if h.state.reachable && h.state.consecutive >= embyOutageThreshold {
h.state.reachable = false
// Dated from the probe that crossed the threshold rather than the first failure:
// the bar reports what the server knows, and until the threshold was crossed it
// did not know anything.
h.state.since = now
}
}
// embyHealthPayload is the shape /v1/status carries. Times are RFC3339 so an old client
// that cannot parse them still renders the bar from reachable and retrySeconds alone.
type embyHealthPayload struct {
Monitored bool `json:"monitored"`
Reachable bool `json:"reachable"`
Since string `json:"since,omitempty"`
CheckedAt string `json:"checkedAt,omitempty"`
RetrySeconds int `json:"retrySeconds"`
}
func embyHealthFor(state embyHealthState) embyHealthPayload {
payload := embyHealthPayload{
Monitored: state.monitored,
Reachable: state.reachable || !state.monitored,
RetrySeconds: int(state.retryEvery / time.Second),
}
if !state.since.IsZero() {
payload.Since = state.since.UTC().Format(time.RFC3339)
}
if !state.checkedAt.IsZero() {
payload.CheckedAt = state.checkedAt.UTC().Format(time.RFC3339)
}
return payload
}
+95
View File
@@ -0,0 +1,95 @@
package api
import (
"testing"
"time"
)
func TestEmbyHealthWaitsForTheThresholdBeforeReportingAnOutage(t *testing.T) {
var health embyHealth
start := time.Now().UTC()
health.begin(60*time.Second, start)
// One failed probe is a hiccup — a restart, a slow scan. A red bar across somebody's
// film for that is worse than saying nothing.
health.record(false, start.Add(time.Minute))
if !health.get().reachable {
t.Fatal("a single failure raised an outage")
}
health.record(false, start.Add(2*time.Minute))
state := health.get()
if state.reachable {
t.Fatal("two consecutive failures did not raise an outage")
}
if !state.since.Equal(start.Add(2 * time.Minute)) {
t.Errorf("since = %v, want the probe that crossed the threshold", state.since)
}
}
// The bar clears itself. One good answer is enough — the failure mode of clearing early
// is a bar that comes back, which is far better than one that outlives the outage.
func TestEmbyHealthRecoversOnTheFirstSuccess(t *testing.T) {
var health embyHealth
start := time.Now().UTC()
health.begin(60*time.Second, start)
health.record(false, start.Add(time.Minute))
health.record(false, start.Add(2*time.Minute))
health.record(true, start.Add(3*time.Minute))
state := health.get()
if !state.reachable {
t.Fatal("a successful probe did not clear the outage")
}
if !state.since.Equal(start.Add(3 * time.Minute)) {
t.Errorf("since = %v, want the recovery time", state.since)
}
}
// A failure that does not reach the threshold must not accumulate across a recovery, or a
// server with one flaky probe an hour eventually reports an outage that never happened.
func TestEmbyHealthForgetsIsolatedFailures(t *testing.T) {
var health embyHealth
start := time.Now().UTC()
health.begin(60*time.Second, start)
for minute := 1; minute <= 10; minute++ {
health.record(minute%2 == 0, start.Add(time.Duration(minute)*time.Minute))
}
if !health.get().reachable {
t.Fatal("alternating failures were reported as an outage")
}
}
// With the probe switched off there is nothing to report, and the client must be told to
// ignore the field rather than trust a value nothing updates.
func TestEmbyHealthPayloadIsReachableWhenUnmonitored(t *testing.T) {
payload := embyHealthFor(embyHealthState{})
if payload.Monitored {
t.Error("an unstarted probe reported itself as monitored")
}
if !payload.Reachable {
t.Error("an unmonitored server was reported unreachable")
}
if payload.Since != "" || payload.CheckedAt != "" {
t.Error("an unstarted probe reported timestamps it never took")
}
}
func TestEmbyHealthPayloadCarriesTheRetryInterval(t *testing.T) {
var health embyHealth
start := time.Now().UTC()
health.begin(60*time.Second, start)
health.record(false, start.Add(time.Minute))
health.record(false, start.Add(2*time.Minute))
payload := embyHealthFor(health.get())
if payload.RetrySeconds != 60 {
t.Errorf("retrySeconds = %d, want 60", payload.RetrySeconds)
}
if payload.Reachable {
t.Error("payload reported a reachable server during an outage")
}
if payload.CheckedAt == "" {
t.Error("payload omitted the last probe time the bar counts down from")
}
}
@@ -0,0 +1,45 @@
package api
import (
"strings"
"testing"
)
func TestContinueWatchingRequestsEpisodeMetadata(t *testing.T) {
fields := strings.Split(fieldsContinue, ",")
want := []string{
"Overview",
"Genres",
"ProductionYear",
"OfficialRating",
"CommunityRating",
"RunTimeTicks",
"SeriesId",
"SeriesName",
"ParentIndexNumber",
"IndexNumber",
}
for _, field := range want {
if !fieldListContains(fields, field) {
t.Errorf("Continue Watching does not request %s", field)
}
}
}
func TestDetailRequestsEpisodeIdentity(t *testing.T) {
fields := strings.Split(fieldsDetail, ",")
for _, field := range []string{"SeriesId", "SeriesName", "ParentIndexNumber", "IndexNumber"} {
if !fieldListContains(fields, field) {
t.Errorf("item detail does not request %s", field)
}
}
}
func fieldListContains(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
+21 -5
View File
@@ -17,6 +17,8 @@ const (
featureAutomaticMyShows = "automatic_my_shows"
featureMyShowsNotification = "my_shows_notifications"
featureHEVCDirectPlay = "hevc_direct_play"
featureInstallPermission = "install_permission_prompt"
featureSubtitleDownload = "subtitle_download"
)
type featureDefinition struct {
@@ -55,6 +57,20 @@ var featureCatalogue = []featureDefinition{
DefaultEnabled: true, MinimumProtocol: 1, Capability: "video_hevc_decode",
Recovery: "Server-enforced; takes effect the next time playback starts or refreshes.",
},
{
Key: featureSubtitleDownload, Name: "Download missing subtitles", Area: "Playback",
Description: "Let a viewer fetch a subtitle through Bazarr from the player, for a " +
"title the library has none for.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "subtitle_download_v1",
Recovery: "Takes effect the next time playback starts; the option simply stops being offered.",
},
{
Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup",
Description: "Ask a signed-in TV that cannot install its own updates to grant the " +
"permission, so a mandatory update is not the first time it comes up.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "install_permission_v1",
Recovery: "Appears on the next status poll, and only on a TV missing the permission.",
},
}
type evaluatedFeature struct {
@@ -103,7 +119,7 @@ func (s *Server) currentFeaturePolicy(ctx context.Context) store.FeaturePolicy {
}
policy, err := s.store.FeaturePolicy(ctx)
if err != nil {
s.log.Warn("feature policy unavailable; using safe defaults", "error", err)
s.loggerFor(ctx).Warn("feature policy unavailable; using safe defaults", "error", err)
return store.DefaultFeaturePolicy()
}
return policy
@@ -114,7 +130,7 @@ func (s *Server) featureEnabled(ctx context.Context, key string) bool {
if !ok {
return false
}
return evaluateFeature(s.currentFeaturePolicy(ctx), definition, membyProtocolVersion).Enabled
return evaluateFeature(s.currentFeaturePolicy(ctx), definition, ProtocolVersion).Enabled
}
func featurePayload(policy store.FeaturePolicy, protocol int, capabilities ...[]string) featureResponse {
@@ -204,13 +220,13 @@ func (s *Server) handleAdminFeaturePolicy(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusConflict, "feature flags changed in another admin session; refresh before saving")
return
}
s.log.Error("feature policy write failed", "error", err)
s.loggerFor(r.Context()).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,
s.loggerFor(r.Context()).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))
writeJSON(w, http.StatusOK, featurePayload(stored, ProtocolVersion))
}
func parseCapabilities(raw string) []string {
+9 -1
View File
@@ -5,14 +5,22 @@ import (
"encoding/hex"
"net/http"
"time"
"github.com/ponzischeme89/memby/server/internal/buildinfo"
)
func hexHash(hash []byte) string { return hex.EncodeToString(hash) }
// handleHealth is liveness: the process is up. It touches no dependency, so an
// orchestrator does not restart the container just because Emby is down.
// It also names the build, so "which server is answering" can be settled with one curl
// against a machine whose logs are not to hand.
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"version": buildinfo.Version(),
"protocol": ProtocolVersion,
})
}
// handleReady is readiness: everything this service needs is reachable.
+121 -19
View File
@@ -11,6 +11,7 @@ import (
"strings"
"sync"
"time"
"unicode"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/cache"
@@ -22,9 +23,16 @@ import (
// Field sets mirror what each TV row actually renders. Asking Emby for less is the
// single biggest lever on home-screen latency, so keep these tight.
const (
fieldsContinue = "RunTimeTicks,SeriesName,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"
// Resume and Next Up contain episodes as well as movies. Keep their descriptive and
// rating fields in the row payload: an episode otherwise reaches the TV with only a
// title and progress, and the first ratings request can cache that incomplete state
// before the richer focus lookup finishes.
fieldsContinue = "Overview,Genres,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio"
fieldsRow = "Genres,Studios,People,CollectionName,OfficialRating,CommunityRating,ProductionYear,PremiereDate,DateCreated,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio"
// Status is a series' production state ("Continuing"/"Ended"). The television needs it
// to decide whether it is estimating a finish or a catch-up, and the detail call is
// where a field like this belongs — adding it to a home query is a startup cost.
fieldsDetail = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName"
fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks"
rowImageTypes = "Backdrop,Primary,Logo"
@@ -61,7 +69,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
radarrSchedule := s.radarr != nil && supportsRadarrSchedule(r)
key := cache.UserKey(
sess.EmbyUserID,
"home:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
"home:v3:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
":r"+strconv.FormatBool(radarrSchedule)+":d"+sess.DeviceID,
)
@@ -80,6 +88,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
radarrRow *recommend.Row
forYouRows []recommend.Row
forYouRowStale bool
seriesPlayed map[string]time.Time
wg sync.WaitGroup
)
@@ -92,7 +101,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
defer mu.Unlock()
if err != nil {
failures++
s.log.Warn("home row failed", "error", err)
s.loggerFor(ctx).Warn("home row failed", "error", err)
return
}
*dest = result.Items
@@ -121,6 +130,21 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
"Limit": {itoa(limit)},
}, fieldsContinue))
})
// What orders the Next Up half of Continue Watching. A failure here is not a failed
// row: the merge falls back to putting the resume items first, which is the order
// the launcher had before the two rows became one.
wg.Add(1)
go func() {
defer wg.Done()
played, err := s.recentlyPlayedSeries(ctx, cred)
if err != nil {
s.loggerFor(ctx).Warn("recently played lookup failed", "error", err)
return
}
mu.Lock()
seriesPlayed = played
mu.Unlock()
}()
run(&out.LatestMovies, func() (*emby.ItemsResult, error) {
newReleaseDays := s.weightedConfig().NewReleaseDays
if newReleaseDays < 1 {
@@ -143,7 +167,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
defer wg.Done()
row, err := s.sonarrAiringTodayRow(ctx)
if err != nil {
s.log.Warn("sonarr calendar row failed", "error", err)
s.loggerFor(ctx).Warn("sonarr calendar row failed", "error", err)
return
}
mu.Lock()
@@ -157,7 +181,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
defer wg.Done()
row, err := s.radarrUpcomingMoviesRow(ctx)
if err != nil {
s.log.Warn("radarr calendar row failed", "error", err)
s.loggerFor(ctx).Warn("radarr calendar row failed", "error", err)
return
}
mu.Lock()
@@ -179,7 +203,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
window := homeForYouWindowAt(time.Now().In(location))
prepared, hit, stale, err := s.forYou.PreparedRows(ctx, sess, window.Minutes)
if err != nil {
s.log.Warn("prepared Home For You row failed", "user", sess.EmbyUserID, "error", err)
s.loggerFor(ctx).Warn("prepared Home For You row failed", "user", sess.EmbyUserID, "error", err)
return
}
if !hit || len(prepared) == 0 {
@@ -204,6 +228,14 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
}
out.Partial = failures > 0
ensureSlices(&out)
// One row, not two: an episode finished a minute ago should be followed by the next
// one at the front of Continue Watching rather than moving the show to a different
// row. NextUp stays on the wire for televisions that predate the merge.
out.ContinueWatching = mergeContinueWatching(out.ContinueWatching, out.NextUp, seriesPlayed)
// A current show with an episode today is more immediately useful than a long-lived
// resume from an ended rewatch. Keep Emby's order inside both groups; this is a
// promotion, not a replacement ranking for Continue Watching.
out.ContinueWatching = prioritizeAiringTodayContinue(out.ContinueWatching, sonarrRow)
// Recommendations are read from their own long-lived cache. A miss means this
// response ships without them and a rebuild starts in the background — the home
@@ -239,30 +271,95 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
sess.EmbyUserID,
time.Now().Add(-45*24*time.Hour),
); err != nil {
s.log.Warn("home row preferences unavailable", "user", sess.EmbyUserID, "error", err)
s.loggerFor(ctx).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)
// Ratings ride on the cards themselves. Only what is already stored is attached, so
// the launcher pays one indexed read rather than a request per poster, and a card
// shows its scores as it is drawn instead of when focus reaches it.
s.decorateHomeRatings(ctx, &out)
body, err := json.Marshal(out)
if err != nil {
s.log.Error("home encode failed", "error", err)
s.loggerFor(ctx).Error("home encode failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not build the home payload")
return
}
// A partial payload is served but never cached: the next request should retry.
if !out.Partial {
if err := s.cache.Set(ctx, key, body, s.cfg.HomeTTL); err != nil {
s.log.Warn("home cache write failed", "error", err)
s.loggerFor(ctx).Warn("home cache write failed", "error", err)
}
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
}
func prioritizeAiringTodayContinue(
items []json.RawMessage,
schedule *recommend.Row,
) []json.RawMessage {
if len(items) < 2 || schedule == nil {
return items
}
today := map[string]bool{}
for _, raw := range schedule.Items {
var entry struct {
Name string `json:"Name"`
Day string `json:"MembyAirDayLabel"`
}
if json.Unmarshal(raw, &entry) == nil && strings.EqualFold(entry.Day, "Today") {
if key := normalizedShowKey(entry.Name); key != "" {
today[key] = true
}
}
}
if len(today) == 0 {
return items
}
promoted := make([]json.RawMessage, 0, len(items))
rest := make([]json.RawMessage, 0, len(items))
for _, raw := range items {
var item struct {
Name string `json:"Name"`
Type string `json:"Type"`
SeriesName string `json:"SeriesName"`
}
if json.Unmarshal(raw, &item) != nil {
rest = append(rest, raw)
continue
}
name := item.Name
if strings.EqualFold(item.Type, "Episode") && strings.TrimSpace(item.SeriesName) != "" {
name = item.SeriesName
}
if !strings.EqualFold(item.Type, "Episode") && !strings.EqualFold(item.Type, "Series") {
rest = append(rest, raw)
continue
}
if today[normalizedShowKey(name)] {
promoted = append(promoted, raw)
} else {
rest = append(rest, raw)
}
}
return append(promoted, rest...)
}
func normalizedShowKey(value string) string {
var b strings.Builder
for _, r := range strings.ToLower(value) {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
}
}
return b.String()
}
// 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
@@ -291,11 +388,8 @@ func personalizeHomeRows(rows []recommend.Row, stats []store.RowStat) []recommen
float64(stat.DwellMs)/30_000
score = (engagement + 2) / (float64(stat.Impressions) + 2)
}
switch row.ID {
case "continue":
if row.ID == "continue" {
score = 1_000
case "next-up":
score += 0.35
}
ranked = append(ranked, rankedRow{row: row, position: position, score: score})
}
@@ -376,7 +470,7 @@ func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess
"EnableUserData": {"true"},
})
if err != nil {
s.writeUpstreamError(w, err, "could not load screensaver items")
s.writeUpstreamError(ctx, w, err, "could not load screensaver items")
return
}
items = result.Items
@@ -418,10 +512,15 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
"Limit": {itoa(limit)},
}, fieldsRow))
if err != nil {
s.writeUpstreamError(w, err, "search failed")
s.writeUpstreamError(ctx, w, err, "search failed")
return
}
items := s.personalizeSearch(ctx, sess, term, result.Items, limit)
s.decorateItemRatings(ctx, items)
// Instant search fires a request per keystroke past the second character, so this is
// DEBUG: it is the record of what somebody was looking for when nothing was found,
// not something to carry in the normal log.
s.loggerFor(ctx).Debug("search", "query", term, "results", len(items))
body, err := json.Marshal(map[string]any{"items": nonNil(items)})
if err != nil {
@@ -429,7 +528,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.SearchTTL); err != nil {
s.log.Warn("search cache write failed", "error", err)
s.loggerFor(ctx).Warn("search cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
@@ -493,10 +592,13 @@ func (s *Server) handleSearchHistory(w http.ResponseWriter, r *http.Request, ses
// Titles live here rather than in the app so wording can change server-side. They are
// emitted even when empty: the client draws its own "Nothing in progress" message, and a
// row that vanishes as you watch things is more jarring than an empty one.
//
// There is deliberately no Next Up row: those episodes are merged into Continue Watching
// by mergeContinueWatching, which is where somebody looks for them the moment an episode
// ends.
func baseRows(h homeResponse) []recommend.Row {
return []recommend.Row{
{ID: "continue", Title: "Continue Watching", Kind: "continue", Items: h.ContinueWatching},
{ID: "next-up", Title: "Next Up", Kind: "nextup", Items: h.NextUp},
{ID: "favorites", Title: "Favourites", Kind: "favorites", Items: h.Favorites},
{ID: "latest-movies", Title: "Recent New Releases", Kind: "latest", Items: h.LatestMovies},
}
+59 -1
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"testing"
"time"
@@ -32,6 +33,29 @@ func TestPersonalizeHomeRowsGraduallyDemotesIgnoredRows(t *testing.T) {
}
}
func TestContinueWatchingPromotesShowsAiringToday(t *testing.T) {
raw := func(value string) json.RawMessage { return json.RawMessage(value) }
items := []json.RawMessage{
raw(`{"Id":"ended","Name":"Old episode","Type":"Episode","SeriesName":"Ended Rewatch"}`),
raw(`{"Id":"movie","Name":"Paused Movie","Type":"Movie"}`),
raw(`{"Id":"today-1","Name":"Episode 4","Type":"Episode","SeriesName":"The Bear"}`),
raw(`{"Id":"today-2","Name":"Episode 2","Type":"Episode","SeriesName":"Abbott Elementary"}`),
}
schedule := &recommend.Row{Items: []json.RawMessage{
raw(`{"Name":"The Bear","MembyAirDayLabel":"Today"}`),
raw(`{"Name":"Tomorrow Show","MembyAirDayLabel":"Tomorrow"}`),
raw(`{"Name":"Abbott Elementary","MembyAirDayLabel":"Today"}`),
}}
got := prioritizeAiringTodayContinue(items, schedule)
want := []string{"today-1", "today-2", "ended", "movie"}
for index, item := range recommend.Decode(got) {
if item.ID != want[index] {
t.Fatalf("item %d = %q, want %q", index, item.ID, want[index])
}
}
}
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 {
@@ -90,8 +114,42 @@ func TestDifferentHistoriesChangeRowAndPosterOrdering(t *testing.T) {
}
}
// Continue Watching answers "what was I watching?", so its order belongs to Emby (and to
// mergeContinueWatching). Ranking it by taste buried episodes behind films and pushed a
// just-watched show past the visible cards, which read on a television as the series
// having vanished.
func TestProgressRowsKeepEmbyOrder(t *testing.T) {
raw := func(id, name, itemType string) json.RawMessage {
value, _ := json.Marshal(map[string]any{"Id": id, "Name": name, "Type": itemType})
return value
}
items := func() []json.RawMessage {
return []json.RawMessage{
raw("episode", "Zulu Company", "Episode"),
raw("movie", "A Film", "Movie"),
}
}
rows := (&Server{}).personalizeTitles(
context.Background(),
store.Session{EmbyUserID: "user"},
[]recommend.Row{
{ID: "continue", Items: items()},
{ID: "curated:movies:drama", Items: items()},
},
)
if got := recommend.Decode(rows[0].Items); len(got) != 2 ||
got[0].ID != "episode" || got[1].ID != "movie" {
t.Fatalf("continue row was reordered: %+v", got)
}
// The same items in a discovery shelf are still ranked, which is what makes the
// exemption above a deliberate choice rather than dead code.
if discovery := recommend.Decode(rows[1].Items); discovery[0].ID != "movie" {
t.Fatalf("discovery row was not ranked: %+v", discovery)
}
}
func TestPersonalizeHomeRowsPreservesColdStartDefaults(t *testing.T) {
rows := []recommend.Row{{ID: "continue"}, {ID: "next-up"}, {ID: "latest"}}
rows := []recommend.Row{{ID: "continue"}, {ID: "favorites"}, {ID: "latest"}}
got := personalizeHomeRows(rows, nil)
for i := range rows {
if got[i].ID != rows[i].ID {
+4 -4
View File
@@ -58,7 +58,7 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
resp, err := s.emby.ImageResponse(r.Context(), credentials(sess), itemID, imageType, params)
if err != nil {
s.writeUpstreamError(w, err, "could not load the image")
s.writeUpstreamError(r.Context(), w, err, "could not load the image")
return
}
defer resp.Body.Close()
@@ -79,7 +79,7 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.
}
w.WriteHeader(http.StatusOK)
copyImage(w, r, resp.Body, s.log,
copyImage(w, r, resp.Body, s.loggerFor(r.Context()),
"source", "emby", "item_id", itemID, "image_type", imageType)
}
@@ -123,7 +123,7 @@ func (s *Server) handleRadarrImage(w http.ResponseWriter, r *http.Request, itemI
}
w.Header().Set("Cache-Control", "private, max-age=3600")
w.WriteHeader(http.StatusOK)
copyImage(w, r, resp.Body, s.log,
copyImage(w, r, resp.Body, s.loggerFor(r.Context()),
"source", "radarr", "item_id", itemID, "image_type", imageType)
}
@@ -167,7 +167,7 @@ func (s *Server) handleSonarrImage(w http.ResponseWriter, r *http.Request, itemI
}
w.Header().Set("Cache-Control", "private, max-age=3600")
w.WriteHeader(http.StatusOK)
copyImage(w, r, resp.Body, s.log,
copyImage(w, r, resp.Body, s.loggerFor(r.Context()),
"source", "sonarr", "item_id", itemID, "image_type", imageType)
}
+1
View File
@@ -92,6 +92,7 @@
<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>If Android asks, allow that app to access files - This is required for app updates.</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}}
+47 -13
View File
@@ -19,6 +19,11 @@ const (
installerSessionTTL = 30 * time.Minute
installerDeviceID = "memby-web-installer"
installerDeviceName = "Memby Web Installer"
// installerRenewWithin is how close to expiry a session must be before an operator's
// own request re-issues it. Half the TTL, so a cookie is rewritten at most once every
// fifteen minutes rather than on every request of a working session.
installerRenewWithin = installerSessionTTL / 2
)
func (s *Server) installerSecret() []byte {
@@ -50,29 +55,58 @@ func (s *Server) newInstallerSession() (string, error) {
base64.RawURLEncoding.EncodeToString(signature), nil
}
func (s *Server) validInstallerSession(r *http.Request) bool {
// installerSessionExpiry reports when the request's session runs out. A cookie that is
// missing, malformed, forged or already expired is reported the same way: no session.
func (s *Server) installerSessionExpiry(r *http.Request) (time.Time, bool) {
if len(s.installerSecret()) == 0 {
return false
return time.Time{}, false
}
cookie, err := r.Cookie(installerCookieName)
if err != nil {
return false
return time.Time{}, false
}
parts := strings.Split(cookie.Value, ".")
if len(parts) != 2 {
return false
return time.Time{}, false
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil || len(payload) != 24 {
return false
return time.Time{}, false
}
signature, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil || !hmac.Equal(signature, s.signInstallerValue("session", payload)) {
return false
return time.Time{}, false
}
expires := int64(binary.BigEndian.Uint64(payload[:8]))
now := time.Now().Unix()
return expires > now && expires <= now+int64(installerSessionTTL/time.Second)+60
if expires <= now || expires > now+int64(installerSessionTTL/time.Second)+60 {
return time.Time{}, false
}
return time.Unix(expires, 0), true
}
func (s *Server) validInstallerSession(r *http.Request) bool {
_, ok := s.installerSessionExpiry(r)
return ok
}
// renewInstallerSession slides a valid session's expiry forward. The TTL was absolute and
// nothing extended it, so an operator working the admin console was signed out from under
// themselves after thirty minutes and the page's poll became a permanent "invalid admin
// token" banner with no sign-in to return to. Callers must only reach here for a request
// an operator actually made — see operatorPresent — or an abandoned tab's own polling
// would keep the session alive indefinitely, which is what the TTL exists to stop.
func (s *Server) renewInstallerSession(w http.ResponseWriter, r *http.Request) {
expires, ok := s.installerSessionExpiry(r)
if !ok || time.Until(expires) > installerRenewWithin {
return
}
session, err := s.newInstallerSession()
if err != nil {
s.loggerFor(r.Context()).Error("installer session renewal failed", "error", err)
return
}
s.setInstallerCookie(w, session)
}
func (s *Server) setInstallerCookie(w http.ResponseWriter, value string) {
@@ -132,7 +166,7 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
// 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.loggerFor(r.Context()).Error("installer login unavailable: MEMBY_SYNC_API_KEY is not configured")
s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
http.StatusServiceUnavailable, "/install")
return
@@ -151,10 +185,10 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
}
auth, err := s.emby.Authenticate(
r.Context(), username, password, installerDeviceID, installerDeviceName,
r.Context(), username, password, installerDeviceID, installerDeviceName, "",
)
if err != nil {
s.log.Warn("installer Emby authentication failed", "username", username)
s.loggerFor(r.Context()).Warn("installer Emby authentication failed", "username", username)
s.renderAccessLogin(w, r, "Sign-in failed.", http.StatusUnauthorized, next)
return
}
@@ -164,13 +198,13 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
UserID: auth.User.ID, Token: auth.AccessToken,
DeviceID: installerDeviceID, DeviceName: installerDeviceName,
}); err != nil {
s.log.Warn("installer Emby session cleanup failed", "error", err)
s.loggerFor(r.Context()).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.loggerFor(r.Context()).Error("installer Emby device cleanup failed", "error", err)
s.renderAccessLogin(w, r, "Sign-in is temporarily unavailable.",
http.StatusBadGateway, next)
return
@@ -178,7 +212,7 @@ func (s *Server) handleInstallLogin(w http.ResponseWriter, r *http.Request) {
session, err := s.newInstallerSession()
if err != nil {
s.log.Error("installer session generation failed", "error", err)
s.loggerFor(r.Context()).Error("installer session generation failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not start installer session")
return
}
+23 -16
View File
@@ -47,8 +47,8 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
return
}
// 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:v3:"+itemID)
// hide newly requested fields such as People or the stored ratings.
key := cache.UserKey(sess.EmbyUserID, "item:v5:"+itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
w.Header().Set("X-Memby-Cache", "hit")
@@ -58,11 +58,16 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
item, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsDetail)
if err != nil {
s.writeUpstreamError(w, err, "could not load the item")
s.writeUpstreamError(ctx, w, err, "could not load the item")
return
}
// A detail page can then draw its ratings with the rest of the hero rather than
// after a second request. Anything not yet stored still arrives on /ratings.
decorated := []json.RawMessage{item}
s.decorateItemRatings(ctx, decorated)
item = decorated[0]
if err := s.cache.Set(ctx, key, item, s.cfg.ItemTTL); err != nil {
s.log.Warn("item cache write failed", "error", err)
s.loggerFor(ctx).Warn("item cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, item)
@@ -94,7 +99,7 @@ func (s *Server) handleSeasonFinale(w http.ResponseWriter, r *http.Request, sess
"ProviderIds,SeriesId,SeriesName,ParentIndexNumber,IndexNumber",
)
if err != nil {
s.writeUpstreamError(w, err, "could not inspect the playing episode")
s.writeUpstreamError(ctx, w, err, "could not inspect the playing episode")
return
}
var current finaleEmbyItem
@@ -107,7 +112,7 @@ func (s *Server) handleSeasonFinale(w http.ResponseWriter, r *http.Request, sess
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.loggerFor(ctx).Warn("season finale series metadata unavailable", "item", itemID, "error", err)
s.writeSeasonFinaleResponse(ctx, key, empty, w)
return
}
@@ -124,7 +129,7 @@ func (s *Server) handleSeasonFinale(w http.ResponseWriter, r *http.Request, sess
series, err := s.sonarr.Series(ctx)
if err != nil {
s.log.Warn("season finale Sonarr series unavailable", "item", itemID, "error", err)
s.loggerFor(ctx).Warn("season finale Sonarr series unavailable", "item", itemID, "error", err)
s.writeSeasonFinaleResponse(ctx, key, empty, w)
return
}
@@ -141,7 +146,7 @@ func (s *Server) handleSeasonFinale(w http.ResponseWriter, r *http.Request, sess
}
episodes, err := s.sonarr.Episodes(ctx, sonarrSeriesID)
if err != nil {
s.log.Warn("season finale Sonarr episodes unavailable", "item", itemID, "error", err)
s.loggerFor(ctx).Warn("season finale Sonarr episodes unavailable", "item", itemID, "error", err)
s.writeSeasonFinaleResponse(ctx, key, empty, w)
return
}
@@ -166,7 +171,7 @@ func (s *Server) writeSeasonFinaleResponse(
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil {
s.log.Warn("season finale cache write failed", "error", err)
s.loggerFor(ctx).Warn("season finale cache write failed", "error", err)
}
writeRaw(w, http.StatusOK, body)
}
@@ -210,8 +215,10 @@ func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, se
}
result, err := s.emby.Episodes(ctx, credentials(sess), seriesID, url.Values{
"UserId": {sess.EmbyUserID},
"Fields": {"Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio"},
"UserId": {sess.EmbyUserID},
// PremiereDate is the one fact that tells two episodes of a list apart, so the
// episode browser asks for it by name — Emby does not return it otherwise.
"Fields": {"Overview,RunTimeTicks,SeriesName,PremiereDate,PrimaryImageAspectRatio"},
"EnableUserData": {"true"},
"EnableImages": {"true"},
"EnableImageTypes": {"Primary,Thumb,Backdrop"},
@@ -219,7 +226,7 @@ func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, se
"Limit": {"1000"},
})
if err != nil {
s.writeUpstreamError(w, err, "could not load series episodes")
s.writeUpstreamError(ctx, w, err, "could not load series episodes")
return
}
items := result.Items
@@ -232,7 +239,7 @@ func (s *Server) handleSeriesEpisodes(w http.ResponseWriter, r *http.Request, se
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.ItemTTL); err != nil {
s.log.Warn("series episodes cache write failed", "error", err)
s.loggerFor(ctx).Warn("series episodes cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
@@ -248,7 +255,7 @@ func (s *Server) handleTrailer(w http.ResponseWriter, r *http.Request, sess stor
}
result, err := s.emby.LocalTrailers(r.Context(), credentials(sess), itemID)
if err != nil {
s.writeUpstreamError(w, err, "could not load trailers")
s.writeUpstreamError(r.Context(), w, err, "could not load trailers")
return
}
if len(result.Items) == 0 {
@@ -291,11 +298,11 @@ func (s *Server) setFlag(
userData, err := apply(itemID, req.Value)
if err != nil {
s.writeUpstreamError(w, err, "could not update the item")
s.writeUpstreamError(r.Context(), w, err, "could not update the item")
return
}
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
s.log.Warn("cache invalidation failed", "error", err)
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
}
if s.forYou != nil {
s.forYou.MarkDirty(r.Context(), sess)
+53
View File
@@ -0,0 +1,53 @@
package api
import "strings"
// The lifecycle word a schedule card wears: whether the show is still being made, whether
// the film has actually come out.
//
// It is *arr's own vocabulary rather than a Memby one, and deliberately so — the household
// operator reads the same word in Sonarr or Radarr, and a card that said something else
// would be a second thing to reconcile. The gateway sends both halves: the slug is what the
// television colours by, the label is what it prints, so a status this build has never
// heard of still reads correctly on a TV that predates it.
type lifecycleTag struct {
Status string
Label string
}
// seriesLifecycleTag maps Sonarr's series status. Anything unrecognised carries no tag at
// all rather than an invented one: a card with no lifecycle is the honest answer when the
// only thing we know is that Sonarr said a word we do not have.
func seriesLifecycleTag(status string) lifecycleTag {
switch strings.ToLower(strings.TrimSpace(status)) {
case "continuing":
return lifecycleTag{Status: "continuing", Label: "CONTINUING"}
case "upcoming":
return lifecycleTag{Status: "upcoming", Label: "UPCOMING"}
case "ended":
return lifecycleTag{Status: "ended", Label: "ENDED"}
case "deleted":
return lifecycleTag{Status: "deleted", Label: "REMOVED"}
default:
return lifecycleTag{}
}
}
// movieLifecycleTag maps Radarr's movie status. "inCinemas" loses its capital on the wire
// because the slug is a lookup key on the television, not prose.
func movieLifecycleTag(status string) lifecycleTag {
switch strings.ToLower(strings.TrimSpace(status)) {
case "tba":
return lifecycleTag{Status: "tba", Label: "TBA"}
case "announced":
return lifecycleTag{Status: "announced", Label: "ANNOUNCED"}
case "incinemas":
return lifecycleTag{Status: "incinemas", Label: "IN CINEMAS"}
case "released":
return lifecycleTag{Status: "released", Label: "RELEASED"}
case "deleted":
return lifecycleTag{Status: "deleted", Label: "REMOVED"}
default:
return lifecycleTag{}
}
}
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"encoding/json"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/sonarr"
)
func TestSeriesLifecycleTag(t *testing.T) {
cases := map[string]lifecycleTag{
"continuing": {Status: "continuing", Label: "CONTINUING"},
"Continuing": {Status: "continuing", Label: "CONTINUING"},
"upcoming": {Status: "upcoming", Label: "UPCOMING"},
"ended": {Status: "ended", Label: "ENDED"},
"deleted": {Status: "deleted", Label: "REMOVED"},
// No tag at all rather than an invented one.
"": {},
"unknown": {},
}
for status, want := range cases {
if got := seriesLifecycleTag(status); got != want {
t.Fatalf("seriesLifecycleTag(%q) = %+v, want %+v", status, got, want)
}
}
}
func TestMovieLifecycleTag(t *testing.T) {
cases := map[string]lifecycleTag{
"announced": {Status: "announced", Label: "ANNOUNCED"},
"inCinemas": {Status: "incinemas", Label: "IN CINEMAS"},
"released": {Status: "released", Label: "RELEASED"},
"tba": {Status: "tba", Label: "TBA"},
"deleted": {Status: "deleted", Label: "REMOVED"},
"": {},
}
for status, want := range cases {
if got := movieLifecycleTag(status); got != want {
t.Fatalf("movieLifecycleTag(%q) = %+v, want %+v", status, got, want)
}
}
}
func TestScheduleRowsCarryLifecycle(t *testing.T) {
now := time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
airs := now.Add(3 * time.Hour)
series := buildSonarrRowForTest(t, sonarr.Episode{
ID: 7, SeriesID: 3, SeasonNumber: 2, EpisodeNumber: 4, AirDateUTC: &airs,
Monitored: true, Series: sonarr.Series{ID: 3, Title: "Severance", Status: "continuing"},
}, now)
if series.MembyLifecycle != "continuing" || series.MembyLifecycleText != "CONTINUING" {
t.Fatalf("sonarr card lifecycle = %q/%q", series.MembyLifecycle, series.MembyLifecycleText)
}
digital := now.Add(24 * time.Hour)
movie := buildRadarrRowForTest(t, radarr.Movie{
ID: 11, Title: "Dune", DigitalRelease: &digital, Monitored: true, Status: "inCinemas",
}, now)
if movie.MembyLifecycle != "incinemas" || movie.MembyLifecycleText != "IN CINEMAS" {
t.Fatalf("radarr card lifecycle = %q/%q", movie.MembyLifecycle, movie.MembyLifecycleText)
}
}
func buildSonarrRowForTest(t *testing.T, episode sonarr.Episode, now time.Time) sonarrScheduleItem {
t.Helper()
row, err := buildSonarrRow([]sonarr.Episode{episode}, now, time.UTC, nil)
if err != nil {
t.Fatalf("buildSonarrRow: %v", err)
}
if len(row.Items) != 1 {
t.Fatalf("expected one card, got %d", len(row.Items))
}
var item sonarrScheduleItem
if err := json.Unmarshal(row.Items[0], &item); err != nil {
t.Fatalf("decode card: %v", err)
}
return item
}
func buildRadarrRowForTest(t *testing.T, movie radarr.Movie, now time.Time) radarrScheduleItem {
t.Helper()
row, err := buildRadarrRow([]radarr.Movie{movie}, now, time.UTC)
if err != nil {
t.Fatalf("buildRadarrRow: %v", err)
}
if len(row.Items) != 1 {
t.Fatalf("expected one card, got %d", len(row.Items))
}
var item radarrScheduleItem
if err := json.Unmarshal(row.Items[0], &item); err != nil {
t.Fatalf("decode card: %v", err)
}
return item
}
+183
View File
@@ -0,0 +1,183 @@
package api
import (
"context"
"log/slog"
"net/http"
"strings"
"github.com/ponzischeme89/memby/server/internal/store"
)
// requestIdentity is the answer to "who did this, from where, on what build" — the
// context every log line from a request needs and no single layer holds. The middleware
// knows the route and the client headers before the session exists; [Server.authed]
// learns the viewer and the television afterwards.
//
// It is carried by pointer through the request context so the outermost middleware can
// still read what an inner layer filled in. A request is served on one goroutine and the
// handler has returned by the time the middleware reads this, so no lock is needed.
type requestIdentity struct {
component string
user string
device string
client string
protocol string
}
type identityKey struct{}
// withRequestIdentity installs an empty identity for this request and returns it.
func withRequestIdentity(r *http.Request) (*http.Request, *requestIdentity) {
identity := &requestIdentity{
component: componentFor(r.URL.Path),
client: clientVersion(r),
protocol: clientProtocol(r),
}
return r.WithContext(context.WithValue(r.Context(), identityKey{}, identity)), identity
}
func identityFrom(ctx context.Context) *requestIdentity {
identity, _ := ctx.Value(identityKey{}).(*requestIdentity)
return identity
}
// identify records who the request turned out to belong to, so every later line — the
// handler's own events and the request line the middleware writes at the end — names the
// viewer and the television rather than a token.
func identify(ctx context.Context, sess store.Session) {
identity := identityFrom(ctx)
if identity == nil {
return
}
if sess.Username != "" {
identity.user = sess.Username
}
if sess.DeviceName != "" {
identity.device = sess.DeviceName
} else if sess.DeviceID != "" {
identity.device = sess.DeviceID
}
if sess.ClientVersion != "" {
identity.client = sess.ClientVersion
}
if sess.ClientProtocol != "" {
identity.protocol = sess.ClientProtocol
}
}
func (i *requestIdentity) attrs() []any {
if i == nil {
return nil
}
attrs := make([]any, 0, 8)
if i.component != "" {
attrs = append(attrs, "component", i.component)
}
attrs = append(attrs, i.viewerAttrs()...)
if i.client != "" {
attrs = append(attrs, "client", i.client)
}
return attrs
}
// viewerAttrs names the person and the television, and only when they are known: an
// unauthenticated probe has neither, and "user=unknown" on every health check is noise.
func (i *requestIdentity) viewerAttrs() []any {
if i == nil {
return nil
}
attrs := make([]any, 0, 4)
if i.user != "" {
attrs = append(attrs, "user", i.user)
}
if i.device != "" {
attrs = append(attrs, "device", i.device)
}
return attrs
}
// loggerFor returns the request's logger: the server logger with the viewer, television,
// app build and area of the app already attached. Handlers use it so an event only has
// to say what happened, and every event from one request is attributable without the
// reader correlating lines by hand.
//
// Outside a request — a scheduled sync, a health probe — it degrades to the plain server
// logger rather than refusing to log.
func (s *Server) loggerFor(ctx context.Context) *slog.Logger {
attrs := identityFrom(ctx).attrs()
if len(attrs) == 0 {
return s.log
}
return s.log.With(attrs...)
}
// componentFor names the part of the app a request came from.
//
// It is derived from the route rather than declared by the client: the TV would have to
// thread a surface name through every repository call to report one, and the route
// already identifies the screen unambiguously — /v1/home is the launcher, an image is
// artwork for something already on screen, /v1/items/{id}/playback is the player asking
// what to play. Deriving it also means an old APK's traffic is attributed correctly.
func componentFor(path string) string {
switch {
case path == "/healthz", path == "/readyz":
return "health"
case path == "/v1/status":
return "status"
case path == "/v1/update", strings.HasPrefix(path, "/updates/"):
return "updates"
case strings.HasPrefix(path, "/v1/auth/devices"):
return "devices"
case strings.HasPrefix(path, "/v1/auth/"):
return "auth"
case path == "/v1/home", path == "/v1/features":
return "home"
case path == "/v1/preferences":
return "settings"
case path == "/v1/screensaver", path == "/v1/preroll":
return "screensaver"
case strings.HasPrefix(path, "/v1/search"):
return "search"
case strings.HasPrefix(path, "/v1/requests"):
return "requests"
case strings.HasPrefix(path, "/v1/recommendations"), path == "/v1/for-you":
return "recommendations"
case strings.HasPrefix(path, "/v1/my-shows"), strings.HasPrefix(path, "/v1/notifications"):
return "my-shows"
case strings.HasPrefix(path, "/v1/playback/"), isPlaybackItemPath(path):
return "playback"
case strings.HasPrefix(path, "/v1/images/"):
return "artwork"
case strings.HasPrefix(path, "/v1/items/"):
return "details"
case strings.HasPrefix(path, "/v1/analytics/"):
return "analytics"
case strings.HasPrefix(path, "/admin"):
return "admin"
case strings.HasPrefix(path, "/hooks/"):
return "webhooks"
case strings.HasPrefix(path, "/install"), path == "/":
return "installer"
default:
return "api"
}
}
// The player's own calls hang off an item, so they are told apart from the detail page by
// their trailing segment rather than their prefix.
func isPlaybackItemPath(path string) bool {
if !strings.HasPrefix(path, "/v1/items/") {
return false
}
// Fetching a subtitle is two segments deep rather than one, and matching its trailing
// "search" on its own would claim any future per-item search as playback.
if strings.Contains(path, "/subtitles/") {
return true
}
switch path[strings.LastIndex(path, "/")+1:] {
case "playback", "next", "trailer":
return true
}
return false
}
+98
View File
@@ -3,7 +3,11 @@ package api
import (
"log/slog"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestRequestLogLevel(t *testing.T) {
@@ -15,6 +19,8 @@ func TestRequestLogLevel(t *testing.T) {
{"/healthz", http.StatusOK, slog.LevelDebug},
{"/v1/status", http.StatusOK, slog.LevelDebug},
{"/v1/images/123/primary", http.StatusOK, slog.LevelDebug},
{"/admin/api/status", http.StatusOK, slog.LevelDebug},
{"/admin/api/status", http.StatusUnauthorized, slog.LevelWarn},
{"/v1/home", http.StatusOK, slog.LevelInfo},
{"/v1/images/123/primary", http.StatusNotFound, slog.LevelWarn},
{"/v1/home", http.StatusServiceUnavailable, slog.LevelError},
@@ -26,6 +32,98 @@ func TestRequestLogLevel(t *testing.T) {
}
}
func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
tests := map[string]string{
"/healthz": "health",
"/v1/status": "status",
"/v1/home": "home",
"/v1/preferences": "settings",
"/v1/screensaver": "screensaver",
"/v1/auth/login": "auth",
"/v1/auth/devices/tv-1": "devices",
"/v1/search": "search",
"/v1/items/42": "details",
"/v1/items/42/related": "details",
"/v1/items/42/playback": "playback",
"/v1/items/42/next": "playback",
"/v1/items/42/subtitles/search": "playback",
"/v1/playback/started": "playback",
"/v1/images/42/primary": "artwork",
"/v1/recommendations": "recommendations",
"/v1/for-you": "recommendations",
"/v1/my-shows": "my-shows",
"/admin/api/status": "admin",
"/hooks/radarr": "webhooks",
"/install": "installer",
"/updates/latest.apk": "updates",
"/something-nobody-has-written": "api",
}
for path, want := range tests {
if got := componentFor(path); got != want {
t.Errorf("componentFor(%q) = %q, want %q", path, got, want)
}
}
}
func TestIdentifyNamesTheViewerAndTelevision(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/v1/home", nil)
request.Header.Set("X-Memby-Version", "0.1.60")
request, identity := withRequestIdentity(request)
identify(request.Context(), store.Session{
Username: "matt", DeviceName: "Living room", ClientProtocol: "1",
})
if identity.component != "home" || identity.user != "matt" || identity.device != "Living room" {
t.Fatalf("identity was not filled in: %+v", identity)
}
// The header the TV actually sent must survive a session that has no version yet.
if identity.client != "0.1.60" || identity.protocol != "1" {
t.Fatalf("client identity was lost: %+v", identity)
}
}
// A device with no name at all must still be identifiable, or an old APK's traffic
// becomes anonymous exactly when someone is trying to work out which television it is.
func TestIdentifyFallsBackToTheDeviceID(t *testing.T) {
request, identity := withRequestIdentity(httptest.NewRequest(http.MethodGet, "/v1/home", nil))
identify(request.Context(), store.Session{DeviceID: "tv-1"})
if identity.device != "tv-1" {
t.Fatalf("device = %q", identity.device)
}
}
func TestPlaybackTitlesNamesReportsAndStaysBounded(t *testing.T) {
var titles playbackTitles
titles.remember("42", "Dune")
if got := titles.name("42"); got != "Dune" {
t.Fatalf("remembered title = %q", got)
}
// An unknown item reports as itself rather than as an empty field.
if got := titles.name("99"); got != "99" {
t.Fatalf("unknown title = %q", got)
}
for i := range rememberedTitles + 10 {
titles.remember(strconv.Itoa(1000+i), "Title")
}
if len(titles.titles) > rememberedTitles {
t.Fatalf("title memory grew to %d entries", len(titles.titles))
}
}
func TestWatchedPercentReadsAsProgress(t *testing.T) {
if got := watchedPercent(30_000, 60_000); got != "50%" {
t.Errorf("half watched = %q", got)
}
// Emby's reported position can overshoot a runtime by a frame or two.
if got := watchedPercent(61_000, 60_000); got != "100%" {
t.Errorf("overshoot = %q", got)
}
if got := watchedPercent(30_000, 0); got != "unknown" {
t.Errorf("unknown runtime = %q", got)
}
}
func TestClientLogValueIsNeverBlank(t *testing.T) {
if got := clientLogValue(""); got != "unknown" {
t.Fatalf("blank identity logged as %q", got)
+13 -4
View File
@@ -36,7 +36,8 @@ func (s *Server) LoadMaintenance(ctx context.Context) error {
}
s.maintenance.set(state)
if state.Enabled {
s.log.Warn("starting in maintenance mode", "message", state.Message)
s.log.Warn("starting in maintenance mode",
"component", "maintenance", "message", state.Message)
}
return nil
}
@@ -52,7 +53,7 @@ func (s *Server) WatchMaintenance(ctx context.Context, interval time.Duration) {
return
case <-ticker.C:
if err := s.LoadMaintenance(ctx); err != nil {
s.log.Warn("maintenance refresh failed", "error", err)
s.log.Warn("maintenance refresh failed", "component", "maintenance", "error", err)
}
}
}
@@ -91,7 +92,7 @@ func (s *Server) maintenanceGate(next http.Handler) http.Handler {
// being discovered the next time a content request happens to run.
// It also carries informational alerts, because this poll is the one thing the app is
// already listening to — a push channel would be a second connection for a banner.
func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, _ store.Session) {
func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, sess store.Session) {
state := s.maintenance.get()
message := state.Message
if state.Enabled && message == "" {
@@ -118,10 +119,18 @@ func (s *Server) handleServiceStatus(w http.ResponseWriter, r *http.Request, _ s
"compatibilityMessage": compatibilityMessage,
"clientVersion": clientVersion(r),
"clientProtocol": clientProtocol(r),
"serverProtocol": membyProtocolVersion,
"serverProtocol": ProtocolVersion,
"featureSchemaVersion": featureSchemaVersion,
"featureRevision": featurePolicy.Revision,
"safeMode": featurePolicy.SafeMode,
"features": featureMap(featurePolicy, clientProtocolNumber(r), clientCapabilities(r)),
// Whether Emby itself is answering, as distinct from whether Memby is. The TV
// direct-plays from Emby, so this is the difference between "the film stopped for
// no reason" and a bar saying what happened and when the next attempt is due.
"emby": embyHealthFor(s.embyHealth.get()),
// One number, not the document: the TV compares it with what it has and only
// fetches /v1/preferences when they differ. That is what turns this poll into the
// delivery channel for an operator pushing someone's settings.
"preferencesRevision": s.preferenceRevisionFor(r, sess),
})
}
+4 -4
View File
@@ -45,7 +45,7 @@ func (s *Server) handleMyShows(w http.ResponseWriter, r *http.Request, sess stor
return
}
if err := s.store.SaveUserShow(r.Context(), sess.EmbyUserID, show); err != nil {
s.log.Error("save user show failed", "error", err)
s.loggerFor(r.Context()).Error("save user show failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save show")
return
}
@@ -62,7 +62,7 @@ func (s *Server) handleMyShow(w http.ResponseWriter, r *http.Request, sess store
return
}
if err := s.store.DeleteUserShow(r.Context(), sess.EmbyUserID, itemID); err != nil {
s.log.Error("delete user show failed", "error", err)
s.loggerFor(r.Context()).Error("delete user show failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not remove show")
return
}
@@ -72,7 +72,7 @@ func (s *Server) handleMyShow(w http.ResponseWriter, r *http.Request, sess store
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)
s.loggerFor(r.Context()).Error("list user shows failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not load shows")
return
}
@@ -81,7 +81,7 @@ func (s *Server) listMyShows(w http.ResponseWriter, r *http.Request, sess store.
if value, seriesErr := s.sonarr.Series(r.Context()); seriesErr == nil {
sonarrSeries = value
} else {
s.log.Warn("Sonarr status unavailable for My Shows", "error", seriesErr)
s.loggerFor(r.Context()).Warn("Sonarr status unavailable for My Shows", "error", seriesErr)
}
}
result := make([]myShowResponse, 0, len(saved))
+126 -46
View File
@@ -27,9 +27,18 @@ type playbackResponse struct {
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
Subtitles []playableSubtitle `json:"subtitles"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
// Which of those tracks to turn on, decided from the viewer's synced settings rather
// than by the television. Empty with SubtitlesEnabled true means "nothing suitable".
SubtitlesEnabled bool `json:"subtitlesEnabled"`
SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
// Whether this gateway can fetch a subtitle the title does not have. It rides here
// rather than on /v1/status because the drop-up is the only thing that asks, it
// already holds this response, and one boolean on a request that is made once per
// playback is cheaper than a field on the poll every open TV makes every ten seconds.
SubtitleDownloadAvailable bool `json:"subtitleDownloadAvailable"`
}
type playableSubtitle struct {
@@ -64,14 +73,16 @@ type playbackReportResponse struct {
// 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.
type nextEpisodeResponse struct {
Item json.RawMessage `json:"item"`
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"`
Item json.RawMessage `json:"item"`
Title string `json:"title"`
URL string `json:"url"`
ResumePositionMs int64 `json:"resumePositionMs"`
Subtitles []playableSubtitle `json:"subtitles"`
SubtitlesEnabled bool `json:"subtitlesEnabled"`
SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
PlayMethod string `json:"playMethod"`
}
// handlePlayback resolves what to actually play.
@@ -92,7 +103,7 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
if !hinted {
raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName")
if err != nil {
s.writeUpstreamError(w, err, "could not load the item")
s.writeUpstreamError(ctx, w, err, "could not load the item")
return
}
item, err = emby.Summarise(raw)
@@ -108,7 +119,7 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
if strings.EqualFold(item.Type, "Series") {
episode, err := s.firstPlayableEpisode(ctx, cred, item.ID)
if err != nil {
s.writeUpstreamError(w, err, "could not find an episode to play")
s.writeUpstreamError(ctx, w, err, "could not find an episode to play")
return
}
if episode == nil {
@@ -135,29 +146,55 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
if negotiatedURL != "" {
streamURL = negotiatedURL
}
subtitlesEnabled, subtitleLanguage := s.subtitlePreferenceFor(ctx, sess)
selectedSubtitleID := selectSubtitle(subtitles, subtitlesEnabled, subtitleLanguage)
// An explicit index is the viewer choosing a burned-in track in the player. It is a
// decision already made, so it outranks the stored preference for this stream.
if subtitleIndex != nil {
subtitlesEnabled, selectedSubtitleID = true, strconv.Itoa(*subtitleIndex)
}
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)
s.loggerFor(ctx).Warn("playback policy unavailable", "error", policyErr)
}
}
// The one event that says what somebody actually tried to watch. It is logged even
// though the request line already records the route, because the route carries an
// item id and nobody can read an item id.
s.playbackTitles.remember(target.ID, title)
s.loggerFor(ctx).Info("playback requested",
"title", title,
"item", target.ID,
"type", target.Type,
"play_method", clientLogValue(playMethod),
"resume", millisecondDuration(target.UserData.PlaybackPositionTicks/ticksPerMillisecond),
"runtime", millisecondDuration(target.RunTimeTicks/ticksPerMillisecond),
"subtitles", len(subtitles),
"subtitle_track", clientLogValue(selectedSubtitleID),
"subtitle_language", clientLogValue(subtitleLanguage),
)
writeJSON(w, http.StatusOK, playbackResponse{
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,
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,
SubtitlesEnabled: subtitlesEnabled,
SelectedSubtitleID: selectedSubtitleID,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx),
})
}
@@ -245,7 +282,7 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
if seriesID == "" {
raw, err := s.emby.Item(ctx, cred, itemID, "SeriesId")
if err != nil {
s.writeUpstreamError(w, err, "could not load the item")
s.writeUpstreamError(ctx, w, err, "could not load the item")
return
}
var parsed struct {
@@ -269,7 +306,7 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
"EnableImageTypes": {"Primary,Thumb"},
})
if err != nil {
s.writeUpstreamError(w, err, "could not load the next episode")
s.writeUpstreamError(ctx, w, err, "could not load the next episode")
return
}
@@ -291,15 +328,31 @@ func (s *Server) handleNextEpisode(w http.ResponseWriter, r *http.Request, sess
if negotiatedURL != "" {
streamURL = negotiatedURL
}
// The same choice as the episode the viewer is already watching, made the same way:
// auto-advance must not quietly drop the subtitles they had on a minute ago.
subtitlesEnabled, subtitleLanguage := s.subtitlePreferenceFor(ctx, sess)
selectedSubtitleID := selectSubtitle(subtitles, subtitlesEnabled, subtitleLanguage)
// The player asks for this ~30s before an episode ends, so the line is also the
// record of an auto-advance about to happen.
s.playbackTitles.remember(next.ID, title)
s.loggerFor(ctx).Info("next episode resolved",
"title", title,
"item", next.ID,
"after_item", itemID,
"play_method", clientLogValue(playMethod),
)
writeJSON(w, http.StatusOK, nextEpisodeResponse{
Item: raw,
Title: title,
URL: streamURL,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
Item: raw,
Title: title,
URL: streamURL,
ResumePositionMs: max64(next.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0),
Subtitles: subtitles,
SubtitlesEnabled: subtitlesEnabled,
SelectedSubtitleID: selectedSubtitleID,
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
PlayMethod: playMethod,
})
}
@@ -313,7 +366,7 @@ func (s *Server) playbackSubtitles(
capabilities,
)
if err != nil {
s.log.Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
s.loggerFor(ctx).Warn("could not load subtitle metadata", "item_id", itemID, "error", err)
return []playableSubtitle{}, itemID, "", "", "DirectPlay"
}
if len(info.MediaSources) == 0 {
@@ -587,6 +640,11 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
return
}
log := s.loggerFor(r.Context()).With(
"title", s.playbackTitles.name(report.ItemID),
"item", report.ItemID,
)
err := s.emby.ReportPlayback(
r.Context(), credentials(sess), phase, report.ItemID, report.MediaSourceID,
report.PlaySessionID, report.PlayMethod, report.EventName,
@@ -594,12 +652,34 @@ func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, se
)
if err != nil {
// A dropped progress report is not worth failing playback over; log and accept.
s.log.Warn("playback report failed", "phase", phase, "error", err)
log.Warn("playback report failed", "phase", phase, "error", err)
}
// Start and stop are the shape of an evening's viewing and belong in the normal log.
// Progress arrives every ten seconds for the length of a film, so it is DEBUG: useful
// when investigating one stall, ruinous as a default.
switch phase {
case "started":
log.Info("playback started",
"position", millisecondDuration(report.PositionMs),
"play_method", clientLogValue(report.PlayMethod),
)
case "stopped":
log.Info("playback stopped",
"position", millisecondDuration(report.PositionMs),
"runtime", millisecondDuration(report.DurationMs),
"watched", watchedPercent(report.PositionMs, report.DurationMs),
)
default:
log.Debug("playback progress",
"position", millisecondDuration(report.PositionMs),
"paused", report.IsPaused,
)
}
if phase == "stopped" {
if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil {
s.log.Warn("cache invalidation failed", "error", err)
s.loggerFor(r.Context()).Warn("cache invalidation failed", "error", err)
}
// Recommendation taste changes slowly. Tracearr marks this user's prepared
// profile dirty only when the session first becomes terminal; the daily builder
@@ -627,7 +707,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
}
rawEpisode, err := s.emby.Item(ctx, credentials(sess), episodeID, "SeriesId")
if err != nil {
s.log.Warn("auto-follow episode lookup failed", "error", err)
s.loggerFor(ctx).Warn("auto-follow episode lookup failed", "error", err)
return ""
}
var episode struct {
@@ -639,7 +719,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
}
rawSeries, err := s.emby.Item(ctx, credentials(sess), episode.SeriesID, "ProductionYear")
if err != nil {
s.log.Warn("auto-follow series lookup failed", "error", err)
s.loggerFor(ctx).Warn("auto-follow series lookup failed", "error", err)
return ""
}
var seriesItem struct {
@@ -652,7 +732,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
}
sonarrSeries, err := s.sonarr.Series(ctx)
if err != nil {
s.log.Warn("auto-follow Sonarr lookup failed", "error", err)
s.loggerFor(ctx).Warn("auto-follow Sonarr lookup failed", "error", err)
return ""
}
matched := matchSonarrSeries(store.UserShow{Title: seriesItem.Name, Year: seriesItem.ProductionYear}, sonarrSeries)
@@ -665,7 +745,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
}
inserted, err := s.store.SaveUserShowIfAbsent(ctx, sess.EmbyUserID, show)
if err != nil {
s.log.Warn("auto-follow save failed", "error", err)
s.loggerFor(ctx).Warn("auto-follow save failed", "error", err)
return ""
}
if !inserted {
@@ -673,7 +753,7 @@ func (s *Server) autoFollowContinuingShow(ctx context.Context, sess store.Sessio
}
prefs, err := s.store.NotificationPreferences(ctx, sess.EmbyUserID)
if err != nil {
s.log.Warn("auto-follow notification preferences unavailable", "error", err)
s.loggerFor(ctx).Warn("auto-follow notification preferences unavailable", "error", err)
return ""
}
if prefs.Enabled && s.featureEnabled(ctx, featureMyShowsNotification) {
+77
View File
@@ -0,0 +1,77 @@
package api
import (
"strconv"
"sync"
"time"
)
// rememberedTitles bounds the title memory below. A household plays a handful of things
// at once; this is generous enough that a report always finds its title and small enough
// that a hostile client cannot grow it into a leak.
const rememberedTitles = 256
// playbackTitles remembers what each item was called when the player asked to play it.
//
// Progress and stop reports carry an item id and nothing else — the client already knows
// the title and has no reason to send it back — so without this every playback event
// after the first reads as an opaque GUID. Deliberately in memory and deliberately lossy:
// a restarted gateway logs an id until the next play, which is a better trade than a
// round trip to Emby on a report the viewer never sees.
type playbackTitles struct {
mu sync.Mutex
titles map[string]string
order []string
}
func (p *playbackTitles) remember(itemID, title string) {
if itemID == "" || title == "" {
return
}
p.mu.Lock()
defer p.mu.Unlock()
if p.titles == nil {
p.titles = make(map[string]string, rememberedTitles)
}
if _, known := p.titles[itemID]; !known {
p.order = append(p.order, itemID)
if len(p.order) > rememberedTitles {
delete(p.titles, p.order[0])
p.order = p.order[1:]
}
}
p.titles[itemID] = title
}
// name falls back to the id rather than an empty field: a line that says which item it
// could not name is still usable.
func (p *playbackTitles) name(itemID string) string {
p.mu.Lock()
defer p.mu.Unlock()
if title, known := p.titles[itemID]; known {
return title
}
return itemID
}
// millisecondDuration renders a playhead as "1h42m30s" rather than a seven-digit
// millisecond count, which no one can read at a glance.
func millisecondDuration(milliseconds int64) time.Duration {
return (time.Duration(max64(milliseconds, 0)) * time.Millisecond).Round(time.Second)
}
// watchedPercent is how a stop is read at a glance: "did they finish it, or give up?"
func watchedPercent(positionMs, durationMs int64) string {
if durationMs <= 0 {
return "unknown"
}
percent := max64(positionMs, 0) * 100 / durationMs
return strconv.FormatInt(min64(percent, 100), 10) + "%"
}
func min64(v, ceiling int64) int64 {
if v > ceiling {
return ceiling
}
return v
}
+543
View File
@@ -0,0 +1,543 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"slices"
"strconv"
"strings"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The vocabulary of a viewer's TV settings, and the only place that decides what a legal
// value is.
//
// It is a catalogue rather than a struct for the same reason featureCatalogue is: the
// admin console renders its editor straight from this list, so a new setting is one entry
// here plus the matching key on the television — never a database migration and never a
// second copy of the rules in a web page. The client and the operator are then editing
// provably the same document, which is the whole point of moving settings off the TV.
//
// Anything that identifies a *television* rather than a person is deliberately absent:
// the device name, the update source, the screensaver's rotation and ring colour. Those
// belong to the box in the room and must not follow someone to another one.
const preferenceSchemaVersion = 1
type preferenceKind string
const (
// A single on/off switch.
preferenceToggle preferenceKind = "toggle"
// Exactly one of Options.
preferenceChoice preferenceKind = "choice"
// An ordered subset of Options, never empty — the order is the setting.
preferenceMulti preferenceKind = "multi"
// An ordered list of free-form ids the server has no vocabulary for (server-composed
// row ids, which change without an app release and so cannot be enumerated here).
preferenceList preferenceKind = "list"
// One of Numbers.
preferenceNumber preferenceKind = "number"
)
type preferenceOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
type preferenceDefinition struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
Area string `json:"area"`
Kind preferenceKind `json:"kind"`
Options []preferenceOption `json:"options,omitempty"`
Numbers []int `json:"numbers,omitempty"`
// Unit names what a number counts, for the admin console's editor. Without it every
// number reads as minutes, which is what the first one to exist happened to be.
Unit string `json:"unit,omitempty"`
Default any `json:"default"`
}
func option(value, label string) preferenceOption {
return preferenceOption{Value: value, Label: label}
}
var preferenceCatalogue = []preferenceDefinition{
{
Key: "homeSections", Name: "Home rows", Area: "Home",
Description: "Which built-in rows the launcher shows, in order.",
Kind: preferenceMulti, Default: []string{"continue", "favorites", "latest"},
Options: []preferenceOption{
option("continue", "Continue watching"),
option("favorites", "Favourites"),
option("latest", "Latest movies"),
},
},
{
Key: "homeCardDensity", Name: "Card size", Area: "Home",
Description: "How large the cards on browse rows are.",
Kind: preferenceChoice, Default: "standard",
Options: []preferenceOption{
option("compact", "Compact"), option("standard", "Standard"), option("large", "Large"),
},
},
{
Key: "homeArtworkStyle", Name: "Card artwork", Area: "Home",
Description: "Poster or backdrop artwork on browse rows.",
Kind: preferenceChoice, Default: "automatic",
Options: []preferenceOption{
option("automatic", "Automatic"), option("poster", "Posters"), option("backdrop", "Backdrops"),
},
},
{
Key: "showHomeCardMetadata", Name: "Card metadata", Area: "Home",
Description: "Show the year, runtime and badges beneath each card.",
Kind: preferenceToggle, Default: true,
},
{
Key: "showRatingsStrip", Name: "Ratings", Area: "Home",
Description: "Show third-party ratings on browse and detail pages.",
Kind: preferenceToggle, Default: true,
},
{
Key: "hideWatchedMovies", Name: "Hide watched films", Area: "Home",
Description: "Keep fully watched films out of browse rows and the hero.",
Kind: preferenceToggle, Default: false,
},
{
Key: "showTitleLogo", Name: "Title logos", Area: "Presentation",
Description: "Use each title's logo artwork in place of plain text.",
Kind: preferenceToggle, Default: true,
},
{
Key: "welcomeQuoteStyle", Name: "Welcome tone", Area: "Presentation",
Description: "Tone of the short line shown after signing in.",
Kind: preferenceChoice, Default: "neutral",
Options: []preferenceOption{
option("neutral", "Neutral"), option("positive", "Positive"), option("homicidal", "Homicidal"),
},
},
{
Key: "autoPlayNextEpisode", Name: "Auto-play next episode", Area: "Playback",
Description: "Roll into the next episode when one finishes.",
Kind: preferenceToggle, Default: true,
},
{
Key: "showTenMinuteReminder", Name: "Ten-minute reminder", Area: "Playback",
Description: "Show the lower-third when ten minutes are left.",
Kind: preferenceToggle, Default: true,
},
{
Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback",
Description: "Turn a subtitle track on automatically when the title has one.",
Kind: preferenceToggle, Default: true,
},
{
Key: "subtitleLanguage", Name: "Subtitle language", Area: "Playback",
Description: "Which language to choose when subtitles are turned on.",
Kind: preferenceChoice, Default: subtitleLanguageAuto,
Options: subtitleLanguageOptions(),
},
{
// The vocabulary is duplicated on the television (data/SeekPreference.kt), which
// normalises anything it does not recognise — so this list may grow an interval
// before every set in the house has the release that understands it.
Key: "seekIntervalSeconds", Name: "Skip interval", Area: "Playback",
Description: "How far one press of Left or Right moves playback, in seconds.",
Kind: preferenceNumber, Default: 10, Numbers: []int{10, 20, 30},
Unit: "seconds",
},
{
Key: "forYouMinutes", Name: "For You duration", Area: "Playback",
Description: "Last time budget chosen in For You. 0 means no limit.",
Kind: preferenceNumber, Default: 0, Numbers: []int{0, 30, 60, 120},
Unit: "minutes",
},
{
Key: "homeRowOrder", Name: "Row order", Area: "Home layout",
Description: "Server row ids in the order this viewer arranged them.",
Kind: preferenceList, Default: []string{},
},
{
Key: "homePinnedRows", Name: "Pinned rows", Area: "Home layout",
Description: "Server row ids kept at the top of the launcher.",
Kind: preferenceList, Default: []string{},
},
{
Key: "homeHiddenRows", Name: "Hidden rows", Area: "Home layout",
Description: "Server row ids this viewer has hidden.",
Kind: preferenceList, Default: []string{},
},
}
// maxListEntries bounds the free-form id lists. They come from a television, and a row
// list long enough to matter is already a bug on that end.
const maxListEntries = 200
// maxListEntryLength bounds one id. Emby ids are short; a server row id is a slug.
const maxListEntryLength = 120
func preferenceDefinitionFor(key string) (preferenceDefinition, bool) {
for _, definition := range preferenceCatalogue {
if definition.Key == key {
return definition, true
}
}
return preferenceDefinition{}, false
}
// normalizePreferences returns a complete, legal document: every known key present, every
// unknown key dropped, every illegal value replaced by its default.
//
// Complete rather than sparse because both readers want it that way — a television
// applies the answer wholesale instead of merging, and the admin console renders an editor
// with nothing missing. Pure, and the one piece of this feature worth testing hard: it is
// what stands between a hand-edited admin request and a launcher that cannot draw a row.
func normalizePreferences(raw map[string]any) map[string]any {
result := make(map[string]any, len(preferenceCatalogue))
for _, definition := range preferenceCatalogue {
result[definition.Key] = normalizePreference(definition, raw[definition.Key])
}
return result
}
func normalizePreference(definition preferenceDefinition, value any) any {
switch definition.Kind {
case preferenceToggle:
if typed, ok := value.(bool); ok {
return typed
}
case preferenceChoice:
if typed, ok := value.(string); ok && hasOption(definition.Options, typed) {
return typed
}
case preferenceMulti:
if selected := stringList(value); len(selected) > 0 {
kept := []string{}
for _, entry := range selected {
if hasOption(definition.Options, entry) && !slices.Contains(kept, entry) {
kept = append(kept, entry)
}
}
// An empty selection is a launcher with no rows at all, so the default
// stands in rather than being honoured as a choice.
if len(kept) > 0 {
return kept
}
}
case preferenceList:
kept := []string{}
for _, entry := range stringList(value) {
entry = strings.TrimSpace(entry)
// Newlines are how the television stores these; one inside an id would
// come back as two rows.
if entry == "" || len(entry) > maxListEntryLength ||
strings.ContainsAny(entry, "\n\r") || slices.Contains(kept, entry) {
continue
}
kept = append(kept, entry)
if len(kept) == maxListEntries {
break
}
}
return kept
case preferenceNumber:
if number, ok := asInt(value); ok && slices.Contains(definition.Numbers, number) {
return number
}
}
return defaultValue(definition)
}
// defaultValue hands back a fresh copy of a slice default. Returning the catalogue's own
// slice would let a caller mutating the result edit the catalogue for the whole process.
func defaultValue(definition preferenceDefinition) any {
if values, ok := definition.Default.([]string); ok {
return append([]string{}, values...)
}
return definition.Default
}
func hasOption(options []preferenceOption, value string) bool {
for _, candidate := range options {
if candidate.Value == value {
return true
}
}
return false
}
func stringList(value any) []string {
switch typed := value.(type) {
case []string:
return typed
case []any:
values := make([]string, 0, len(typed))
for _, entry := range typed {
if text, ok := entry.(string); ok {
values = append(values, text)
}
}
return values
}
return nil
}
// asInt accepts float64 because that is what encoding/json produces for every number.
func asInt(value any) (int, bool) {
switch typed := value.(type) {
case float64:
return int(typed), typed == float64(int(typed))
case int:
return typed, true
}
return 0, false
}
func decodePreferences(raw json.RawMessage) map[string]any {
values := map[string]any{}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &values)
}
return normalizePreferences(values)
}
type preferencesResponse struct {
SchemaVersion int `json:"schemaVersion"`
Revision int64 `json:"revision"`
UpdatedAt any `json:"updatedAt,omitempty"`
Source string `json:"source,omitempty"`
Preferences map[string]any `json:"preferences"`
Catalogue []preferenceDefinition `json:"catalogue"`
}
func preferencesPayload(stored store.UserPreferences) preferencesResponse {
payload := preferencesResponse{
SchemaVersion: preferenceSchemaVersion, Revision: stored.Revision,
Source: stored.Source, Preferences: decodePreferences(stored.Preferences),
Catalogue: preferenceCatalogue,
}
if !stored.UpdatedAt.IsZero() {
payload.UpdatedAt = stored.UpdatedAt
}
return payload
}
type preferencesRequest struct {
// Revision is the revision the television believes it is editing. A mismatch is a
// 409 and never a retry: the loser re-reads, because the other writer is usually the
// operator and silently overwriting them is exactly what this endpoint must not do.
Revision int64 `json:"revision"`
Preferences map[string]any `json:"preferences"`
}
// handlePreferences is one handler for both verbs because they answer with the same
// document, and a client that has just written wants what was stored, not what it sent.
func (s *Server) handlePreferences(w http.ResponseWriter, r *http.Request, sess store.Session) {
if r.Method == http.MethodGet {
stored, err := s.store.UserPreferences(r.Context(), sess.EmbyUserID)
if err != nil {
s.loggerFor(r.Context()).Error("preferences read failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read your settings")
return
}
s.recordPreferenceAck(r, sess, stored.Revision)
writeJSON(w, http.StatusOK, preferencesPayload(stored))
return
}
var req preferencesRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
raw, err := json.Marshal(normalizePreferences(req.Preferences))
if err != nil {
writeError(w, http.StatusBadRequest, "could not read those settings")
return
}
stored, err := s.store.SetUserPreferences(r.Context(), sess.EmbyUserID, store.PreferenceWrite{
Preferences: raw, ExpectedRevision: req.Revision, Source: "device",
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName, ClientVersion: sess.ClientVersion,
})
if err != nil {
if errors.Is(err, store.ErrPreferencesConflict) {
// The current document rides along with the 409 so the television can adopt
// it without a second round trip — which matters, because the usual cause is
// an operator push it has not caught up with yet.
current, readErr := s.store.UserPreferences(r.Context(), sess.EmbyUserID)
if readErr != nil {
writeError(w, http.StatusConflict, "your settings changed elsewhere; reload them")
return
}
// The television adopts what came back rather than retrying, so this set now
// holds the winner's revision as surely as if it had fetched it.
s.recordPreferenceAck(r, sess, current.Revision)
writeJSON(w, http.StatusConflict, preferencesPayload(current))
return
}
s.loggerFor(r.Context()).Error("preferences write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save your settings")
return
}
s.recordPreferenceAck(r, sess, stored.Revision)
s.loggerFor(r.Context()).Info("preferences saved", "revision", stored.Revision, "source", "device")
writeJSON(w, http.StatusOK, preferencesPayload(stored))
}
// recordPreferenceAck notes that this television now holds this revision.
//
// Best-effort on purpose, and never in front of the response: the viewer's settings have
// already been read or written correctly, and losing the operator's view of which sets are
// up to date is not worth failing that. A set whose ack is dropped records the next one.
func (s *Server) recordPreferenceAck(r *http.Request, sess store.Session, revision int64) {
if s.store == nil {
return
}
if err := s.store.RecordPreferenceAck(r.Context(), sess.EmbyUserID, sess.DeviceID,
sess.DeviceName, sess.ClientVersion, revision); err != nil {
s.loggerFor(r.Context()).Warn("preference acknowledgement not recorded", "error", err)
}
}
// subtitlePreferenceFor reads this viewer's subtitle choice out of their synced settings,
// falling back to the catalogue defaults for anyone who has never expressed one.
//
// A read failure is not an error a viewer should ever see: it costs them the preferred
// language for one launch, and refusing to resolve playback over a settings row that would
// not load would be a much worse trade.
func (s *Server) subtitlePreferenceFor(ctx context.Context, sess store.Session) (bool, string) {
enabled, _ := preferenceDefault("subtitlesEnabled").(bool)
language, _ := preferenceDefault("subtitleLanguage").(string)
if s.store == nil || sess.EmbyUserID == "" {
return enabled, language
}
stored, err := s.store.UserPreferences(ctx, sess.EmbyUserID)
if err != nil {
s.loggerFor(ctx).Warn("subtitle preference unavailable", "error", err)
return enabled, language
}
document := decodePreferences(stored.Preferences)
if value, ok := document["subtitlesEnabled"].(bool); ok {
enabled = value
}
if value, ok := document["subtitleLanguage"].(string); ok {
language = value
}
return enabled, language
}
// preferenceChange is one setting that differs between two revisions, described in the
// words the catalogue uses rather than in the keys and raw values the document holds.
type preferenceChange struct {
Key string `json:"key"`
Name string `json:"name"`
Area string `json:"area"`
Before string `json:"before"`
After string `json:"after"`
}
// preferenceChanges is what a history row actually says: "Card size: Standard → Large",
// not two JSON documents for the operator to compare by eye.
//
// Pure, and driven entirely by the catalogue, so a setting added tomorrow is described
// without touching this. It walks the catalogue rather than the documents on purpose —
// a key that has since been retired is not a change anybody can act on, and a value stored
// before a setting existed would otherwise read as one appearing out of nothing.
func preferenceChanges(before, after map[string]any) []preferenceChange {
changes := []preferenceChange{}
for _, definition := range preferenceCatalogue {
was := normalizePreference(definition, before[definition.Key])
now := normalizePreference(definition, after[definition.Key])
if preferenceValueLabel(definition, was) == preferenceValueLabel(definition, now) {
continue
}
changes = append(changes, preferenceChange{
Key: definition.Key, Name: definition.Name, Area: definition.Area,
Before: preferenceValueLabel(definition, was),
After: preferenceValueLabel(definition, now),
})
}
return changes
}
// preferenceValueLabel is one stored value as a person reads it. Comparing labels rather
// than values is deliberate: two documents that render identically have not changed
// anything an operator could see, and a history row saying otherwise is noise.
func preferenceValueLabel(definition preferenceDefinition, value any) string {
switch definition.Kind {
case preferenceToggle:
if enabled, _ := value.(bool); enabled {
return "On"
}
return "Off"
case preferenceChoice:
selected, _ := value.(string)
for _, option := range definition.Options {
if option.Value == selected {
return option.Label
}
}
return selected
case preferenceNumber:
number, ok := asInt(value)
if !ok {
return ""
}
if number == 0 && definition.Unit != "" {
return "No limit"
}
if definition.Unit != "" {
return strconv.Itoa(number) + " " + definition.Unit
}
return strconv.Itoa(number)
case preferenceMulti:
labels := []string{}
for _, entry := range stringList(value) {
label := entry
for _, option := range definition.Options {
if option.Value == entry {
label = option.Label
}
}
labels = append(labels, label)
}
if len(labels) == 0 {
return "None"
}
return strings.Join(labels, ", ")
case preferenceList:
entries := stringList(value)
if len(entries) == 0 {
return "None"
}
return strings.Join(entries, ", ")
}
return ""
}
func preferenceDefault(key string) any {
definition, ok := preferenceDefinitionFor(key)
if !ok {
return nil
}
return defaultValue(definition)
}
// preferenceRevisionFor is the number /v1/status carries. A read failure reports 0 rather
// than an error: the poll's other job is maintenance, and losing that because a settings
// row could not be read would be a much worse trade.
func (s *Server) preferenceRevisionFor(r *http.Request, sess store.Session) int64 {
if s.store == nil || sess.EmbyUserID == "" {
return 0
}
revision, err := s.store.UserPreferenceRevision(r.Context(), sess.EmbyUserID)
if err != nil {
s.loggerFor(r.Context()).Warn("preference revision unavailable", "error", err)
return 0
}
return revision
}
+170
View File
@@ -0,0 +1,170 @@
package api
import (
"encoding/json"
"reflect"
"testing"
)
// The document a television applies must always be complete. A missing key is a setting
// with no value on the other end, which is how a launcher ends up with no rows.
func TestNormalizePreferencesFillsEveryKnownKey(t *testing.T) {
result := normalizePreferences(nil)
if len(result) != len(preferenceCatalogue) {
t.Fatalf("got %d keys, want %d", len(result), len(preferenceCatalogue))
}
for _, definition := range preferenceCatalogue {
if _, ok := result[definition.Key]; !ok {
t.Errorf("%s missing from a normalised document", definition.Key)
}
}
}
func TestNormalizePreferencesDropsUnknownKeys(t *testing.T) {
result := normalizePreferences(map[string]any{"embyToken": "secret", "showTitleLogo": false})
if _, ok := result["embyToken"]; ok {
t.Error("an unknown key survived normalisation")
}
if result["showTitleLogo"] != false {
t.Errorf("showTitleLogo = %v, want false", result["showTitleLogo"])
}
}
func TestNormalizePreferencesRejectsIllegalValues(t *testing.T) {
result := normalizePreferences(map[string]any{
"homeCardDensity": "enormous",
"showRatingsStrip": "yes",
"forYouMinutes": 45,
"homeSections": []any{"nonsense"},
})
if result["homeCardDensity"] != "standard" {
t.Errorf("homeCardDensity = %v, want the default", result["homeCardDensity"])
}
if result["showRatingsStrip"] != true {
t.Errorf("showRatingsStrip = %v, want the default", result["showRatingsStrip"])
}
if result["forYouMinutes"] != 0 {
t.Errorf("forYouMinutes = %v, want the default", result["forYouMinutes"])
}
// Every named row was unknown, which leaves nothing to draw — the default stands in
// rather than an empty launcher being honoured as a choice.
want := []string{"continue", "favorites", "latest"}
if !reflect.DeepEqual(result["homeSections"], want) {
t.Errorf("homeSections = %v, want %v", result["homeSections"], want)
}
}
// The order of a multi-select is the setting, not an implementation detail: it is the
// order the rows appear in on the launcher.
func TestNormalizePreferencesKeepsMultiOrderAndDedupes(t *testing.T) {
result := normalizePreferences(map[string]any{
"homeSections": []any{"latest", "continue", "latest", "unknown"},
})
want := []string{"latest", "continue"}
if !reflect.DeepEqual(result["homeSections"], want) {
t.Errorf("homeSections = %v, want %v", result["homeSections"], want)
}
}
// Free-form row ids are stored newline-separated on the television, so an id containing
// one would come back as two rows on the next sync.
func TestNormalizePreferencesRejectsNewlinesInRowIds(t *testing.T) {
result := normalizePreferences(map[string]any{
"homeRowOrder": []any{"recommended", "bad\nid", " ", "recommended", "latest"},
})
want := []string{"recommended", "latest"}
if !reflect.DeepEqual(result["homeRowOrder"], want) {
t.Errorf("homeRowOrder = %v, want %v", result["homeRowOrder"], want)
}
}
func TestNormalizePreferencesBoundsListLength(t *testing.T) {
ids := make([]any, 0, maxListEntries+50)
for i := 0; i < maxListEntries+50; i++ {
ids = append(ids, string(rune('a'+i%26))+string(rune('a'+i/26)))
}
result := normalizePreferences(map[string]any{"homeHiddenRows": ids})
if got := len(result["homeHiddenRows"].([]string)); got > maxListEntries {
t.Errorf("kept %d ids, want at most %d", got, maxListEntries)
}
}
// A default that is a slice must be copied out, or a caller mutating one user's document
// edits the catalogue for every user for the life of the process.
func TestNormalizePreferencesDoesNotShareSliceDefaults(t *testing.T) {
first := normalizePreferences(nil)
first["homeSections"].([]string)[0] = "tampered"
second := normalizePreferences(nil)
if second["homeSections"].([]string)[0] != "continue" {
t.Fatal("mutating one document changed the catalogue default")
}
}
// Numbers arrive from encoding/json as float64; a document that has been through the wire
// must normalise identically to one built in Go.
func TestNormalizePreferencesAcceptsJSONNumbers(t *testing.T) {
var decoded map[string]any
if err := json.Unmarshal([]byte(`{"forYouMinutes":60}`), &decoded); err != nil {
t.Fatal(err)
}
if got := normalizePreferences(decoded)["forYouMinutes"]; got != 60 {
t.Errorf("forYouMinutes = %v, want 60", got)
}
}
// The client applies whatever comes back, so a round trip through storage must be a
// fixed point — otherwise two televisions could disagree about what they just agreed on.
func TestNormalizePreferencesIsIdempotentThroughJSON(t *testing.T) {
once := normalizePreferences(map[string]any{
"homeSections": []any{"favorites"}, "homeCardDensity": "large",
"hideWatchedMovies": true, "homeRowOrder": []any{"recommended"},
})
raw, err := json.Marshal(once)
if err != nil {
t.Fatal(err)
}
twice := decodePreferences(raw)
rawOnce, _ := json.Marshal(once)
rawTwice, _ := json.Marshal(twice)
if string(rawOnce) != string(rawTwice) {
t.Errorf("round trip changed the document:\n once: %s\ntwice: %s", rawOnce, rawTwice)
}
}
// The catalogue is the contract the admin console and the television both read. A default
// that is not itself a legal value would hand every new viewer something the editor
// cannot represent.
func TestPreferenceCatalogueDefaultsAreLegal(t *testing.T) {
seen := map[string]bool{}
for _, definition := range preferenceCatalogue {
if seen[definition.Key] {
t.Errorf("%s appears in the catalogue twice", definition.Key)
}
seen[definition.Key] = true
if definition.Name == "" || definition.Area == "" {
t.Errorf("%s needs a name and an area to render in the console", definition.Key)
}
normalised := normalizePreference(definition, defaultValue(definition))
if !reflect.DeepEqual(normalised, defaultValue(definition)) {
t.Errorf("%s default %v is not a legal value (normalises to %v)",
definition.Key, definition.Default, normalised)
}
}
}
// The skip interval is a number from a fixed list, and the television normalises it a
// second time. Both ends have to agree on which values exist, or a viewer's choice comes
// back as the default the moment they change some other setting.
func TestNormalizePreferencesSeekInterval(t *testing.T) {
if got := normalizePreferences(map[string]any{"seekIntervalSeconds": 30})["seekIntervalSeconds"]; got != 30 {
t.Errorf("seekIntervalSeconds = %v, want 30", got)
}
// 15 is not offered; neither is a string. Both fall back rather than reaching a player
// as a step size nothing on the television knows how to label.
for _, value := range []any{15, "30", 0, nil} {
got := normalizePreferences(map[string]any{"seekIntervalSeconds": value})["seekIntervalSeconds"]
if got != 10 {
t.Errorf("seekIntervalSeconds for %v = %v, want the default 10", value, got)
}
}
}
+9 -3
View File
@@ -12,7 +12,7 @@ import (
"github.com/ponzischeme89/memby/server/internal/recommend"
)
const radarrCalendarCachePrefix = "radarr:calendar:v2:"
const radarrCalendarCachePrefix = "radarr:calendar:v3:"
const radarrScheduleDays = 5
const radarrTheatricalDelayDays = 30
@@ -37,7 +37,11 @@ type radarrScheduleItem struct {
MembyAirLabel string `json:"MembyAirLabel"`
MembyAvailability string `json:"MembyAvailability"`
MembyAvailabilityText string `json:"MembyAvailabilityText"`
MembyPlayable bool `json:"MembyPlayable"`
// Radarr's lifecycle for the title — announced, in cinemas, released — which is a
// different question from whether the household's copy has downloaded yet.
MembyLifecycle string `json:"MembyLifecycle,omitempty"`
MembyLifecycleText string `json:"MembyLifecycleText,omitempty"`
MembyPlayable bool `json:"MembyPlayable"`
}
func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, error) {
@@ -78,7 +82,7 @@ func (s *Server) radarrUpcomingMoviesRow(ctx context.Context) (*recommend.Row, e
}
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)
s.loggerFor(ctx).Warn("radarr calendar cache write failed", "error", cacheErr)
}
}
return row, nil
@@ -171,6 +175,8 @@ func toRadarrScheduleItem(movie radarr.Movie, release radarrRelease, now time.Ti
MembyAvailabilityText: availabilityText,
MembyPlayable: false,
}
lifecycle := movieLifecycleTag(movie.Status)
item.MembyLifecycle, item.MembyLifecycleText = lifecycle.Status, lifecycle.Label
if hasRadarrCover(movie.Images, "poster") {
item.ImageTags["Primary"] = "radarr"
}
+2 -2
View File
@@ -61,7 +61,7 @@ func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) {
// 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")
s.loggerFor(r.Context()).Info("radarr webhook test received")
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "test": true})
return
}
@@ -79,7 +79,7 @@ func (s *Server) handleRadarrWebhook(w http.ResponseWriter, r *http.Request) {
}
s.publishAlert(r.Context(), alert, s.cfg.RadarrAlertWindow)
s.log.Info("radarr import announced",
s.loggerFor(r.Context()).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})
}
+200 -20
View File
@@ -172,6 +172,9 @@ func (s *Server) personalizeTitles(
location := s.cfg.SonarrLocation
for index := range rows {
row := &rows[index]
if progressRow(row.ID) {
continue
}
compatibility := map[string]float64{}
for _, raw := range row.Items {
var marker struct {
@@ -208,14 +211,27 @@ func (s *Server) personalizeTitles(
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
}
row.Items = items
}
return rows
}
// progressRow marks the row that answers "what was I watching?" rather than "what might
// I like?" — and it is the only row whose order is not ours to decide.
//
// Continue Watching is Emby's resume list interleaved with Next Up, most-recently-watched
// first, which is the whole usefulness of it. Ranking it by taste reordered that: an
// episode carries none of the studio, cast or collection fields a film's payload does,
// its Type has no affinity evidence behind it, and a 22-minute runtime fits a household
// session profile built from features badly — so films sorted to the front and the show
// somebody was two episodes into sorted past the visible cards. Finishing an episode then
// looked like the series had vanished, because the one place it could be found was ordered
// by something other than having just been watched. The diversity caps and the exploration
// shuffle compound it for the same reason.
func progressRow(id string) bool {
return id == "continue"
}
func (s *Server) personalizeSearch(
ctx context.Context,
sess store.Session,
@@ -259,9 +275,8 @@ func (s *Server) weightedConfig() recommend.WeightedConfig {
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.
// deduplicateRows gives the earliest row ownership of a title. Continue Watching keeps
// its 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 {
@@ -317,8 +332,6 @@ func personalizeRowsByTitleScores(rows []recommend.Row) []recommend.Row {
switch row.ID {
case "continue":
score = 1_000
case "next-up":
score = 100
case "latest-movies":
score = 90
}
@@ -341,7 +354,7 @@ 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":
case "continue", "latest-movies", "favorites":
out = append(out, row)
continue
}
@@ -432,7 +445,21 @@ func (s *Server) handleRecommendationPreferences(
return
}
}
peopleCount := len(preferences.Actors) + len(preferences.Actresses) + len(preferences.Directors)
if peopleCount > 60 {
writeError(w, http.StatusBadRequest, "too many onboarding people")
return
}
for _, names := range [][]string{preferences.Actors, preferences.Actresses, preferences.Directors} {
for _, name := range names {
if strings.TrimSpace(name) == "" || len(name) > 160 {
writeError(w, http.StatusBadRequest, "invalid onboarding person")
return
}
}
}
preferences.Completed = true
preferences.Prompted = true
raw, _ := json.Marshal(preferences)
if err := s.store.SetRecommendationOnboarding(
r.Context(), sess.EmbyUserID, raw,
@@ -449,9 +476,21 @@ func (s *Server) handleRecommendationPreferences(
}
type recommendationOnboardingResponse struct {
Completed bool `json:"completed"`
Ratings map[string]int `json:"ratings"`
Items []json.RawMessage `json:"items"`
Completed bool `json:"completed"`
Prompted bool `json:"prompted"`
Ratings map[string]int `json:"ratings"`
Items []json.RawMessage `json:"items"`
Movies []json.RawMessage `json:"movies"`
Shows []json.RawMessage `json:"shows"`
Actors []recommendationOnboardingPerson `json:"actors"`
Actresses []recommendationOnboardingPerson `json:"actresses"`
Directors []recommendationOnboardingPerson `json:"directors"`
}
type recommendationOnboardingPerson struct {
ID string `json:"id"`
Name string `json:"name"`
ImageTag string `json:"imageTag"`
}
func (s *Server) handleRecommendationPreferencesGet(
@@ -466,9 +505,16 @@ func (s *Server) handleRecommendationPreferencesGet(
if preferences.Ratings == nil {
preferences.Ratings = map[string]int{}
}
if preferences.Completed {
// Older TVs only understand completed. Treat an uninvited profile as complete on the
// wire so server-side prompt control also suppresses the legacy automatic flow. The
// stored value remains false; queueing a prompt changes Prompted and the next request
// receives the real incomplete state.
if preferences.Completed || !preferences.Prompted {
writeJSON(w, http.StatusOK, recommendationOnboardingResponse{
Completed: true, Ratings: preferences.Ratings, Items: []json.RawMessage{},
Completed: true, Prompted: preferences.Prompted, Ratings: preferences.Ratings, Items: []json.RawMessage{},
Movies: []json.RawMessage{}, Shows: []json.RawMessage{},
Actors: []recommendationOnboardingPerson{}, Actresses: []recommendationOnboardingPerson{},
Directors: []recommendationOnboardingPerson{},
})
return
}
@@ -477,7 +523,7 @@ func (s *Server) handleRecommendationPreferencesGet(
writeError(w, http.StatusInternalServerError, "could not load rating choices")
return
}
candidates := recommendationOnboardingCandidates(recommend.Decode(raws), 24)
candidates := recommendationOnboardingCandidates(recommend.Decode(raws), 40)
row := recommend.Row{ID: "for-you:onboarding", Kind: "for-you"}
for _, item := range candidates {
row.Items = append(row.Items, item.Raw)
@@ -488,17 +534,136 @@ func (s *Server) handleRecommendationPreferencesGet(
items := []json.RawMessage{}
if len(filtered) == 1 {
items = filtered[0].Items
if len(items) > 16 {
items = items[:16]
if len(items) > 32 {
items = items[:32]
}
}
movies, shows := []json.RawMessage{}, []json.RawMessage{}
visible := recommend.Decode(items)
for _, item := range visible {
if strings.EqualFold(item.Type, "Movie") && len(movies) < 16 {
movies = append(movies, item.Raw)
}
if strings.EqualFold(item.Type, "Series") && len(shows) < 16 {
shows = append(shows, item.Raw)
}
}
actors, actresses, directors := recommendationOnboardingPeople(visible, 16)
writeJSON(w, http.StatusOK, recommendationOnboardingResponse{
Completed: preferences.Completed,
Prompted: preferences.Prompted,
Ratings: preferences.Ratings,
Items: items,
Movies: movies, Shows: shows,
Actors: actors, Actresses: actresses, Directors: directors,
})
}
// recommendationOnboardingPeople turns the cast and crew already visible to this user
// into recognisable portrait choices. Emby identifies all performers as Actor, so the
// actress split uses a deliberately curated, case-insensitive list; unfamiliar names
// remain in Actors rather than being guessed from a name.
func recommendationOnboardingPeople(items []recommend.Item, limit int) (
[]recommendationOnboardingPerson, []recommendationOnboardingPerson, []recommendationOnboardingPerson,
) {
type candidate struct {
person recommendationOnboardingPerson
appearances int
bestRating float64
}
groups := [3]map[string]*candidate{{}, {}, {}}
for _, item := range items {
seen := map[string]bool{}
for _, person := range item.People {
name := strings.TrimSpace(person.Name)
key := strings.ToLower(name)
if name == "" || seen[key] {
continue
}
seen[key] = true
group := -1
switch strings.ToLower(strings.TrimSpace(person.Type)) {
case "director":
group = 2
case "actor":
if onboardingActresses[key] {
group = 1
} else {
group = 0
}
}
if group < 0 {
continue
}
value := groups[group][key]
if value == nil {
value = &candidate{person: recommendationOnboardingPerson{ID: person.ID, Name: name, ImageTag: person.PrimaryImageTag}}
groups[group][key] = value
}
value.appearances++
if item.CommunityRating > value.bestRating {
value.bestRating = item.CommunityRating
}
if value.person.ID == "" && person.ID != "" {
value.person.ID, value.person.ImageTag = person.ID, person.PrimaryImageTag
}
}
}
output := func(values map[string]*candidate) []recommendationOnboardingPerson {
all := make([]*candidate, 0, len(values))
for _, value := range values {
all = append(all, value)
}
sort.Slice(all, func(i, j int) bool {
if all[i].appearances != all[j].appearances {
return all[i].appearances > all[j].appearances
}
if all[i].bestRating != all[j].bestRating {
return all[i].bestRating > all[j].bestRating
}
return strings.ToLower(all[i].person.Name) < strings.ToLower(all[j].person.Name)
})
if len(all) > limit {
all = all[:limit]
}
out := make([]recommendationOnboardingPerson, 0, len(all))
for _, value := range all {
out = append(out, value.person)
}
return out
}
return output(groups[0]), output(groups[1]), output(groups[2])
}
var onboardingActresses = map[string]bool{
"amy adams": true, "cate blanchett": true, "viola davis": true, "zendaya": true,
"michelle yeoh": true, "lupita nyong'o": true, "florence pugh": true, "saoirse ronan": true,
"margot robbie": true, "emma stone": true, "scarlett johansson": true, "natalie portman": true,
"jessica chastain": true, "octavia spencer": true, "regina king": true, "taraji p. henson": true,
"tilda swinton": true, "frances mcdormand": true, "olivia colman": true, "kate winslet": true,
"nicole kidman": true, "toni collette": true, "kirsten dunst": true, "rachel weisz": true,
"ana de armas": true, "anya taylor-joy": true, "aunjanue ellis-taylor": true, "danai gurira": true,
"gemma chan": true, "greta lee": true, "janelle monáe": true, "kerry washington": true,
"ming-na wen": true, "rosamund pike": true, "ruth negga": true, "sandra oh": true,
"salma hayek": true, "sonoya mizuno": true, "tessa thompson": true,
"thandiwe newton": true, "zoë saldaña": true, "meryl streep": true, "jodie foster": true,
"sigourney weaver": true, "angela bassett": true, "gillian anderson": true, "elisabeth moss": true,
"jennifer coolidge": true, "quinta brunson": true, "ayo edebiri": true, "bella ramsey": true,
"emily blunt": true, "jennifer lawrence": true, "anne hathaway": true, "rachel mcadams": true,
"charlize theron": true, "halle berry": true, "brie larson": true, "rebecca ferguson": true,
"julia roberts": true, "sandra bullock": true, "reese witherspoon": true, "jennifer aniston": true,
"laura dern": true, "julianne moore": true, "glenn close": true, "helen mirren": true,
"judi dench": true, "maggie smith": true, "kathy bates": true, "carey mulligan": true,
"alicia vikander": true, "noomi rapace": true, "marion cotillard": true, "léa seydoux": true,
"penélope cruz": true, "deepika padukone": true, "priyanka chopra jonas": true, "awkwafina": true,
"constance wu": true, "zoë kravitz": true, "gwendoline christie": true, "emilia clarke": true,
"lena headey": true, "sarah snook": true, "jodie comer": true, "issa rae": true,
"uzo aduba": true, "natasha lyonne": true, "catherine o'hara": true, "jean smart": true,
"melanie lynskey": true, "lucy lawless": true, "rose mciver": true, "thomasin mckenzie": true,
"keisha castle-hughes": true, "rena owen": true, "elizabeth debicki": true, "sarah paulson": true,
"jenna ortega": true, "hailee steinfeld": true, "millie bobby brown": true, "kristen stewart": true,
}
// 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.
@@ -515,6 +680,7 @@ func recommendationOnboardingCandidates(items []recommend.Item, limit int) []rec
buckets := map[string][]recommend.Item{"movie": {}, "series": {}}
typeCounts := map[string]int{}
genreCounts := map[string]int{}
eraCounts := map[string]int{}
perType := max(1, limit/2)
for _, item := range items {
kind := strings.ToLower(strings.TrimSpace(item.Type))
@@ -526,12 +692,26 @@ func recommendationOnboardingCandidates(items []recommend.Item, limit int) []rec
if len(item.Genres) > 0 {
genre = strings.ToLower(strings.TrimSpace(item.Genres[0]))
}
if genre != "" && genreCounts[genre] >= 3 {
genreKey := kind + ":" + genre
era := "classic"
if item.ProductionYear >= 2020 {
era = "current"
} else if item.ProductionYear >= 2000 {
era = "modern"
} else if item.ProductionYear >= 1980 {
era = "catalogue"
}
eraKey := kind + ":" + era
if genre != "" && genreCounts[genreKey] >= max(2, perType/4) {
continue
}
if eraCounts[eraKey] >= max(3, perType/2) {
continue
}
buckets[kind] = append(buckets[kind], item)
typeCounts[kind]++
genreCounts[genre]++
genreCounts[genreKey]++
eraCounts[eraKey]++
if typeCounts["movie"]+typeCounts["series"] == limit {
break
}
+151 -26
View File
@@ -12,7 +12,33 @@ import (
"github.com/ponzischeme89/memby/server/internal/store"
)
const mdblistRatingsTTL = 24 * time.Hour
const (
// The Redis entry is only the faster first hop. Postgres is the durable cache and is
// what decides whether an external request happens at all.
mdblistRatingsTTL = 24 * time.Hour
// How old a stored response may be before it is refreshed. Critics' scores move
// slowly and the operator's quota is a daily allowance, so a stored value is always
// served immediately and any refresh happens behind the request.
ratingsRefreshInterval = 30 * 24 * time.Hour
// A title MDBList had nothing for is retried sooner: a film released this week
// genuinely gains scores, and the empty answer is cheap to have been wrong about.
ratingsEmptyRefreshInterval = 3 * 24 * time.Hour
)
// ratingsNeedRefresh decides whether a stored response should be renewed. It never
// decides whether one is *served* — a stored value, however old, beats an empty strip.
func ratingsNeedRefresh(ratings []mdblist.Rating, fetchedAt, now time.Time) bool {
if fetchedAt.IsZero() {
return true
}
age := now.Sub(fetchedAt)
if len(ratings) == 0 {
return age >= ratingsEmptyRefreshInterval
}
return age >= ratingsRefreshInterval
}
type movieRating struct {
Source string `json:"source"`
@@ -41,6 +67,9 @@ var movieRatingSources = map[string]ratingSource{
"tmdb": {Name: "TMDb", Scale: "/10", Maximum: 10},
"trakt": {Name: "Trakt", Scale: "%", Maximum: 100},
"mal": {Name: "MyAnimeList", Scale: "/10", Maximum: 10},
"anilist": {Name: "AniList", Scale: "%", Maximum: 100},
"anidb": {Name: "AniDB", Scale: "/10", Maximum: 10},
"kitsu": {Name: "Kitsu", Scale: "%", Maximum: 100},
"score": {Name: "MDBList Score", Scale: "/100", Maximum: 100},
"score_average": {
Name: "MDBList Average", Scale: "/100", Maximum: 100,
@@ -54,11 +83,13 @@ var movieRatingAliases = map[string]string{
"metacritic": "metacritic", "letterboxd": "letterboxd",
"rogerebert": "rogerebert", "roger_ebert": "rogerebert",
"tmdb": "tmdb", "trakt": "trakt", "mal": "mal", "myanimelist": "mal",
"anilist": "anilist", "anidb": "anidb", "kitsu": "kitsu",
"score": "score", "score_average": "score_average", "scoreaverage": "score_average",
}
type ratingsEmbyItem struct {
Type string `json:"Type"`
SeriesID string `json:"SeriesId"`
ProviderIDs map[string]string `json:"ProviderIds"`
}
@@ -72,27 +103,38 @@ func (s *Server) handleMovieRatings(w http.ResponseWriter, r *http.Request, sess
writeJSON(w, http.StatusOK, empty)
return
}
if s.store == nil || s.emby == nil || s.cache == nil || s.mdblist == nil {
if s.store == nil || s.emby == nil || s.mdblist == nil {
writeJSON(w, http.StatusOK, empty)
return
}
settings, err := s.store.MDBListSettings(r.Context())
if err != nil || !settings.Enabled || settings.APIKey == "" || len(settings.Sources) == 0 {
if err != nil && s.log != nil {
s.log.Warn("MDBList settings unavailable", "error", err)
}
settings, enabled := s.mdblistSettings(r.Context())
if !enabled {
writeJSON(w, http.StatusOK, empty)
return
}
rawItem, err := s.emby.Item(r.Context(), credentials(sess), itemID, "ProviderIds")
rawItem, err := s.emby.Item(r.Context(), credentials(sess), itemID, "ProviderIds,SeriesId")
if err != nil {
s.logMDBListFailure("movie identifiers unavailable", itemID, err)
s.logMDBListFailure(r.Context(), "movie identifiers unavailable", itemID, err)
writeJSON(w, http.StatusOK, empty)
return
}
var item ratingsEmbyItem
if json.Unmarshal(rawItem, &item) != nil || !strings.EqualFold(item.Type, "Movie") {
if json.Unmarshal(rawItem, &item) != nil {
writeJSON(w, http.StatusOK, empty)
return
}
mediaType := "movie"
if strings.EqualFold(item.Type, "Series") {
mediaType = "show"
} else if strings.EqualFold(item.Type, "Episode") && item.SeriesID != "" {
seriesRaw, seriesErr := s.emby.Item(r.Context(), credentials(sess), item.SeriesID, "ProviderIds")
if seriesErr != nil || json.Unmarshal(seriesRaw, &item) != nil {
writeJSON(w, http.StatusOK, empty)
return
}
mediaType = "show"
} else if !strings.EqualFold(item.Type, "Movie") {
writeJSON(w, http.StatusOK, empty)
return
}
@@ -101,10 +143,15 @@ func (s *Server) handleMovieRatings(w http.ResponseWriter, r *http.Request, sess
writeJSON(w, http.StatusOK, empty)
return
}
key := store.RatingKey{MediaType: mediaType, Provider: provider, ProviderID: providerID}
// Remember what this item is called externally. A row can then attach its rating
// from the database without spending an Emby request per card, and the index fills
// in as the household browses rather than waiting on a full library import.
s.rememberRatingRef(r.Context(), itemID, key)
ratings, err := s.loadMDBListRatings(r.Context(), settings.APIKey, provider, providerID)
ratings, err := s.loadMDBListRatings(r.Context(), settings.APIKey, key)
if err != nil {
s.logMDBListFailure("ratings unavailable", itemID, err)
s.logMDBListFailure(r.Context(), "ratings unavailable", itemID, err)
writeJSON(w, http.StatusOK, empty)
return
}
@@ -113,36 +160,74 @@ func (s *Server) handleMovieRatings(w http.ResponseWriter, r *http.Request, sess
})
}
// loadMDBListRatings answers from the durable cache whenever it holds anything at all.
// An external request happens only for a title never seen before; an ageing one is
// renewed behind the viewer by the warmer, so browsing never waits on MDBList and a
// household's daily quota is spent on new titles rather than on repeat visits.
func (s *Server) loadMDBListRatings(
ctx context.Context, apiKey, provider, providerID string,
ctx context.Context, apiKey string, key store.RatingKey,
) ([]mdblist.Rating, error) {
key := "mdblist:movie-ratings:v1:" + provider + ":" + providerID
if ratings, ok := s.cachedMDBListRatings(ctx, key); ok {
cacheKey := ratingsCacheKey(key)
if ratings, ok := s.cachedMDBListRatings(ctx, cacheKey); ok {
return ratings, nil
}
stored, fetchedAt, hasPersistent := s.persistedMDBListRatings(ctx, key)
if hasPersistent {
s.cacheMDBListRatings(ctx, cacheKey, stored)
if ratingsNeedRefresh(stored, fetchedAt, time.Now()) {
s.warmRatings(key)
}
return stored, nil
}
// A viewer can move focus rapidly and open the same title before the first request
// finishes. Double-checking under the lock keeps that from spending quota twice.
s.mdblistMu.Lock()
defer s.mdblistMu.Unlock()
if ratings, ok := s.cachedMDBListRatings(ctx, key); ok {
if ratings, ok := s.cachedMDBListRatings(ctx, cacheKey); ok {
return ratings, nil
}
ratings, err := s.mdblist.Movie(ctx, apiKey, provider, providerID)
// Recheck Postgres under the lock: another request may have populated it while this
// request waited. Postgres is the durable cache; Redis is only the faster first hop.
if stored, _, hasPersistent = s.persistedMDBListRatings(ctx, key); hasPersistent {
s.cacheMDBListRatings(ctx, cacheKey, stored)
return stored, nil
}
return s.fetchAndStoreRatings(ctx, apiKey, key)
}
// fetchAndStoreRatings is the only place an external request is made, so the durable
// write and the hot cache can never disagree about what was fetched.
func (s *Server) fetchAndStoreRatings(
ctx context.Context, apiKey string, key store.RatingKey,
) ([]mdblist.Rating, error) {
ratings, err := s.mdblist.Media(ctx, apiKey, key.Provider, key.ProviderID, key.MediaType)
if err != nil {
return nil, err
}
if ratings == nil {
ratings = []mdblist.Rating{}
}
if raw, marshalErr := json.Marshal(ratings); marshalErr == nil {
if cacheErr := s.cache.Set(ctx, key, raw, mdblistRatingsTTL); cacheErr != nil && s.log != nil {
s.log.Warn("MDBList rating cache write failed", "error", cacheErr)
}
raw, marshalErr := json.Marshal(ratings)
if marshalErr != nil {
return ratings, nil
}
if saveErr := s.store.SaveMediaRatings(
ctx, key.MediaType, key.Provider, key.ProviderID, raw,
); saveErr != nil && s.log != nil {
s.loggerFor(ctx).Warn("MDBList rating database write failed", "error", saveErr)
}
s.cacheMDBListRatings(ctx, ratingsCacheKey(key), ratings)
return ratings, nil
}
func ratingsCacheKey(key store.RatingKey) string {
return "mdblist:ratings:v2:" + key.MediaType + ":" + key.Provider + ":" + key.ProviderID
}
func (s *Server) cachedMDBListRatings(ctx context.Context, key string) ([]mdblist.Rating, bool) {
if s.cache == nil {
return nil, false
}
raw, err := s.cache.Get(ctx, key)
if err != nil {
return nil, false
@@ -157,9 +242,42 @@ func (s *Server) cachedMDBListRatings(ctx context.Context, key string) ([]mdblis
return ratings, true
}
func (s *Server) logMDBListFailure(message, itemID string, err error) {
func (s *Server) persistedMDBListRatings(
ctx context.Context, key store.RatingKey,
) ([]mdblist.Rating, time.Time, bool) {
raw, fetchedAt, err := s.store.MediaRatings(ctx, key.MediaType, key.Provider, key.ProviderID)
if err != nil {
if err != store.ErrMediaRatingsNotFound && s.log != nil {
s.loggerFor(ctx).Warn("MDBList rating database read failed", "error", err)
}
return nil, time.Time{}, false
}
var ratings []mdblist.Rating
if json.Unmarshal(raw, &ratings) != nil {
return nil, time.Time{}, false
}
if ratings == nil {
ratings = []mdblist.Rating{}
}
return ratings, fetchedAt, true
}
func (s *Server) cacheMDBListRatings(ctx context.Context, key string, ratings []mdblist.Rating) {
if s.cache == nil {
return
}
raw, err := json.Marshal(ratings)
if err != nil {
return
}
if err := s.cache.Set(ctx, key, raw, mdblistRatingsTTL); err != nil && s.log != nil {
s.loggerFor(ctx).Warn("MDBList rating cache write failed", "error", err)
}
}
func (s *Server) logMDBListFailure(ctx context.Context, message, itemID string, err error) {
if s.log != nil {
s.log.Debug("MDBList "+message, "item", itemID, "error", err)
s.loggerFor(ctx).Debug("MDBList "+message, "item", itemID, "error", err)
}
}
@@ -196,7 +314,7 @@ func selectedMovieRatings(selected []string, available []mdblist.Rating) []movie
}
seen[id] = true
result = append(result, movieRating{
Source: id, Name: source.Name, Score: formatRatingScore(value), Scale: source.Scale,
Source: id, Name: source.Name, Score: formatRatingScore(value, source), Scale: source.Scale,
})
}
if result == nil {
@@ -205,6 +323,13 @@ func selectedMovieRatings(selected []string, available []mdblist.Rating) []movie
return result
}
func formatRatingScore(value float64) string {
return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(value, 'f', 2, 64), "0"), ".")
// formatRatingScore writes a score the way its own scale is read. A fractional scale
// always shows its decimal — IMDb 7 is written "7.0", because "7" beside an "8.2" reads
// as a different kind of number rather than the same one that happens to be round — and
// a scale measured in whole points (percentages, /100) never grows one.
func formatRatingScore(value float64, source ratingSource) string {
if source.Maximum <= 10 {
return strconv.FormatFloat(value, 'f', 1, 64)
}
return strconv.FormatFloat(value, 'f', 0, 64)
}
+411
View File
@@ -0,0 +1,411 @@
package api
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/mdblist"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
// MembyRatings rides on the item payload so a card can draw its ratings the moment the
// row does. The dedicated /v1/items/{id}/ratings endpoint remains for anything the
// database has never seen — this field is only ever what was already stored, so
// attaching it costs one indexed read for a whole launcher and never an external
// request on the request path.
const ratingsItemField = "MembyRatings"
// A launcher is a few hundred cards at most. The cap is a guard against a future row
// type asking for a thousand, not a limit anything reaches today.
const ratingsAttachItemLimit = 600
const (
// The warmer's pace. MDBList sells a daily allowance rather than a rate, so the
// interval only stops a burst of navigation becoming a burst of requests, and the
// daily budget is what actually protects the quota.
ratingsWarmInterval = 2 * time.Second
ratingsWarmDailyBudget = 400
// A refused request means the allowance is gone; asking again inside the same hour
// only spends the operator's rate limit on nothing.
ratingsWarmBackoff = time.Hour
ratingsWarmQueue = 512
)
// How long the operator's ratings settings are trusted in memory. Every row, search and
// detail response now consults them, and instant search asks once per keystroke — that
// is a Postgres round trip per key press for a document changed a few times a year. The
// admin write clears this, so a change still takes effect at once.
const mdblistSettingsTTL = 30 * time.Second
type mdblistSettingsCache struct {
mu sync.Mutex
settings store.MDBListSettings
loadedAt time.Time
}
// mdblistSettings reports the ratings configuration, and whether ratings are on at all.
func (s *Server) mdblistSettings(ctx context.Context) (store.MDBListSettings, bool) {
if s.store == nil {
return store.MDBListSettings{}, false
}
c := &s.mdblistSettingsCache
now := time.Now()
c.mu.Lock()
if !c.loadedAt.IsZero() && now.Sub(c.loadedAt) < mdblistSettingsTTL {
settings := c.settings
c.mu.Unlock()
return settings, settings.Enabled && settings.APIKey != "" && len(settings.Sources) > 0
}
c.mu.Unlock()
settings, err := s.store.MDBListSettings(ctx)
if err != nil {
s.loggerFor(ctx).Warn("MDBList settings unavailable", "error", err)
return store.MDBListSettings{}, false
}
c.mu.Lock()
c.settings, c.loadedAt = settings, now
c.mu.Unlock()
return settings, settings.Enabled && settings.APIKey != "" && len(settings.Sources) > 0
}
// forgetMDBListSettings is called by the admin write so an operator's change is live on
// the next request rather than at the end of the cache window.
func (s *Server) forgetMDBListSettings() {
c := &s.mdblistSettingsCache
c.mu.Lock()
c.loadedAt = time.Time{}
c.mu.Unlock()
}
// ratingsWarmer renews and fills the durable cache behind the viewer.
//
// Navigation is what feeds it: every row that reaches a television reports the titles it
// could not decorate, and those are fetched once and kept. The queue is bounded and
// deliberately lossy — a title dropped now is offered again the next time somebody
// scrolls past it, which is a far better failure than a growing backlog of requests
// against a daily allowance.
type ratingsWarmer struct {
once sync.Once
queue chan store.RatingKey
mu sync.Mutex
queued map[store.RatingKey]bool
spent int
windowFrom time.Time
blockedTil time.Time
}
// warmRatings offers titles to the background warmer. It never blocks the caller.
func (s *Server) warmRatings(keys ...store.RatingKey) {
if s.mdblist == nil || s.store == nil || len(keys) == 0 {
return
}
w := &s.ratingsWarm
w.once.Do(func() {
w.queue = make(chan store.RatingKey, ratingsWarmQueue)
w.queued = map[store.RatingKey]bool{}
go s.ratingsWarmLoop()
})
for _, key := range keys {
if key.MediaType == "" || key.Provider == "" || key.ProviderID == "" {
continue
}
w.mu.Lock()
already := w.queued[key]
if !already {
w.queued[key] = true
}
w.mu.Unlock()
if already {
continue
}
select {
case w.queue <- key:
default:
// Full. Forget it rather than wait: the next row that shows this title will
// offer it again, and a blocked handler would be paying for a cache fill.
w.mu.Lock()
delete(w.queued, key)
w.mu.Unlock()
}
}
}
func (s *Server) ratingsWarmLoop() {
w := &s.ratingsWarm
for key := range w.queue {
w.mu.Lock()
delete(w.queued, key)
w.mu.Unlock()
s.warmOne(key)
time.Sleep(ratingsWarmInterval)
}
}
func (s *Server) warmOne(key store.RatingKey) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
settings, enabled := s.mdblistSettings(ctx)
if !enabled {
return
}
if !s.claimRatingsBudget(time.Now()) {
return
}
if _, err := s.fetchAndStoreRatings(ctx, settings.APIKey, key); err != nil {
var apiErr *mdblist.APIError
if errors.As(err, &apiErr) && (apiErr.StatusCode == 429 || apiErr.StatusCode == 402) {
s.blockRatingsWarming(time.Now().Add(ratingsWarmBackoff))
}
if s.log != nil {
s.log.Debug("MDBList warm failed",
"component", "ratings", "provider", key.Provider, "id", key.ProviderID, "error", err)
}
return
}
if s.log != nil {
s.log.Debug("MDBList rating stored",
"component", "ratings", "provider", key.Provider, "id", key.ProviderID)
}
}
// claimRatingsBudget spends one of the day's allowed external requests. The window is
// in-process: a restart forgives what was already spent, which is the right way round
// for a counter whose only job is to stop a runaway fill.
func (s *Server) claimRatingsBudget(now time.Time) bool {
w := &s.ratingsWarm
w.mu.Lock()
defer w.mu.Unlock()
if now.Before(w.blockedTil) {
return false
}
if w.windowFrom.IsZero() || now.Sub(w.windowFrom) >= 24*time.Hour {
w.windowFrom = now
w.spent = 0
}
if w.spent >= ratingsWarmDailyBudget {
return false
}
w.spent++
return true
}
func (s *Server) blockRatingsWarming(until time.Time) {
w := &s.ratingsWarm
w.mu.Lock()
defer w.mu.Unlock()
w.blockedTil = until
}
// rememberRatingRef records an item's external identity without delaying the response
// that discovered it.
func (s *Server) rememberRatingRef(ctx context.Context, itemID string, key store.RatingKey) {
if s.store == nil || itemID == "" {
return
}
ctx = context.WithoutCancel(ctx)
go func() {
writeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := s.store.SaveItemRatingRef(writeCtx, itemID, key); err != nil && s.log != nil {
s.loggerFor(ctx).Debug("rating reference write failed", "item", itemID, "error", err)
}
}()
}
// decorateHomeRatings attaches stored ratings to every collection of items in one pass.
//
// One pass rather than one per row: the fixed rows and the composed rows share their
// backing arrays, so decorating each separately would resolve the same ids repeatedly,
// and writing back in place is what keeps both views agreeing.
func (s *Server) decorateHomeRatings(ctx context.Context, out *homeResponse) {
collections := make([][]json.RawMessage, 0, len(out.Rows)+4)
for _, row := range out.Rows {
collections = append(collections, row.Items)
}
collections = append(collections,
out.ContinueWatching, out.NextUp, out.Favorites, out.LatestMovies)
s.decorateItemRatings(ctx, collections...)
}
// decorateItemRatings rewrites each item in place with whatever the database already
// holds for it, and offers the rest to the warmer.
func (s *Server) decorateItemRatings(ctx context.Context, collections ...[]json.RawMessage) {
settings, enabled := s.mdblistSettings(ctx)
if !enabled {
return
}
ids := itemIDsIn(collections, ratingsAttachItemLimit)
if len(ids) == 0 {
return
}
keys := s.ratingKeysForItems(ctx, ids)
if len(keys) == 0 {
return
}
stored, err := s.store.MediaRatingsBatch(ctx, distinctRatingKeys(keys))
if err != nil {
s.loggerFor(ctx).Warn("stored ratings read failed", "error", err)
return
}
now := time.Now()
byItem := make(map[string][]movieRating, len(keys))
warm := make([]store.RatingKey, 0)
warmed := make(map[store.RatingKey]bool, len(keys))
for itemID, key := range keys {
entry, ok := stored[key]
var ratings []mdblist.Rating
if ok {
if json.Unmarshal(entry.Ratings, &ratings) != nil {
ratings = nil
}
byItem[itemID] = selectedMovieRatings(settings.Sources, ratings)
}
if (!ok || ratingsNeedRefresh(ratings, entry.FetchedAt, now)) && !warmed[key] {
warmed[key] = true
warm = append(warm, key)
}
}
for _, items := range collections {
for index, raw := range items {
id := itemIDOf(raw)
ratings, ok := byItem[id]
if !ok || len(ratings) == 0 {
continue
}
items[index] = injectItemRatings(raw, ratings)
}
}
// Only what the household actually looked at is warmed, which is what keeps a large
// library from being imported into MDBList's quota all at once.
s.warmRatings(warm...)
}
// decorateRowRatings is the row-shaped entry point used outside Home.
func (s *Server) decorateRowRatings(ctx context.Context, rows []recommend.Row) {
collections := make([][]json.RawMessage, 0, len(rows))
for _, row := range rows {
collections = append(collections, row.Items)
}
s.decorateItemRatings(ctx, collections...)
}
// ratingKeysForItems resolves Emby ids to external titles, preferring the index built by
// navigation and falling back to the imported library.
func (s *Server) ratingKeysForItems(ctx context.Context, ids []string) map[string]store.RatingKey {
keys, err := s.store.ItemRatingRefs(ctx, ids)
if err != nil {
s.loggerFor(ctx).Warn("rating references read failed", "error", err)
keys = map[string]store.RatingKey{}
}
missing := make([]string, 0, len(ids))
for _, id := range ids {
if _, ok := keys[id]; !ok {
missing = append(missing, id)
}
}
if len(missing) == 0 {
return keys
}
refs, err := s.store.LibraryProviderIDs(ctx, missing)
if err != nil {
s.loggerFor(ctx).Warn("library provider ids read failed", "error", err)
return keys
}
for id, ref := range refs {
if key, ok := ratingKeyFor(ref.Type, ref.ProviderIDs); ok {
keys[id] = key
}
}
return keys
}
// ratingKeyFor applies the same rule the live lookup does: MDBList rates films and
// shows, an episode is rated as its series, and tmdb wins over imdb.
func ratingKeyFor(itemType string, ids map[string]string) (store.RatingKey, bool) {
var mediaType string
switch {
case strings.EqualFold(itemType, "Movie"):
mediaType = "movie"
case strings.EqualFold(itemType, "Series"), strings.EqualFold(itemType, "Episode"):
mediaType = "show"
default:
return store.RatingKey{}, false
}
provider, providerID := movieProvider(ids)
if providerID == "" {
return store.RatingKey{}, false
}
return store.RatingKey{MediaType: mediaType, Provider: provider, ProviderID: providerID}, true
}
func distinctRatingKeys(keys map[string]store.RatingKey) []store.RatingKey {
seen := make(map[store.RatingKey]bool, len(keys))
out := make([]store.RatingKey, 0, len(keys))
for _, key := range keys {
if seen[key] {
continue
}
seen[key] = true
out = append(out, key)
}
return out
}
// injectItemRatings adds the ratings field to one item payload. Emby's JSON is otherwise
// forwarded verbatim, so this decodes into raw members and re-encodes rather than
// through any struct: a field this build does not know about must survive the round trip.
func injectItemRatings(raw json.RawMessage, ratings []movieRating) json.RawMessage {
if len(ratings) == 0 {
return raw
}
var members map[string]json.RawMessage
if json.Unmarshal(raw, &members) != nil || members == nil {
return raw
}
encoded, err := json.Marshal(ratings)
if err != nil {
return raw
}
members[ratingsItemField] = encoded
out, err := json.Marshal(members)
if err != nil {
return raw
}
return out
}
func itemIDOf(raw json.RawMessage) string {
var item struct {
ID string `json:"Id"`
}
if json.Unmarshal(raw, &item) != nil {
return ""
}
return item.ID
}
func itemIDsIn(collections [][]json.RawMessage, limit int) []string {
seen := make(map[string]bool)
ids := make([]string, 0, limit)
for _, items := range collections {
for _, raw := range items {
id := itemIDOf(raw)
if id == "" || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
if len(ids) >= limit {
return ids
}
}
}
return ids
}
+121 -1
View File
@@ -1,9 +1,12 @@
package api
import (
"encoding/json"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/mdblist"
"github.com/ponzischeme89/memby/server/internal/store"
)
func TestSelectedMovieRatingsFiltersOrdersAndLabelsAvailableValues(t *testing.T) {
@@ -20,13 +23,34 @@ func TestSelectedMovieRatingsFiltersOrdersAndLabelsAvailableValues(t *testing.T)
if len(got) != 3 {
t.Fatalf("ratings = %+v", got)
}
if got[0] != (movieRating{Source: "letterboxd", Name: "Letterboxd", Score: "4.15", Scale: "/5"}) ||
if got[0] != (movieRating{Source: "letterboxd", Name: "Letterboxd", Score: "4.2", Scale: "/5"}) ||
got[1].Name != "IMDb" || got[1].Score != "8.2" || got[1].Scale != "/10" ||
got[2].Name != "Rotten Tomatoes" || got[2].Score != "91" || got[2].Scale != "%" {
t.Fatalf("formatted ratings = %+v", got)
}
}
// A round score is the case the strip is read on: "7" beside "8.2" looks like a score out
// of a different scale, so a fractional scale keeps its decimal and a whole one never
// gains one.
func TestFormatRatingScoreKeepsTheDecimalOfAFractionalScale(t *testing.T) {
for _, tc := range []struct {
source string
value float64
want string
}{
{"imdb", 7, "7.0"},
{"imdb", 8.26, "8.3"},
{"letterboxd", 4, "4.0"},
{"tomatoes", 91, "91"},
{"metacritic", 87.4, "87"},
} {
if got := formatRatingScore(tc.value, movieRatingSources[tc.source]); got != tc.want {
t.Fatalf("%s %v = %q, want %q", tc.source, tc.value, got, tc.want)
}
}
}
func TestMovieProviderPrefersTMDBAndFallsBackToIMDb(t *testing.T) {
provider, id := movieProvider(map[string]string{"ImDb": "tt0111161", "TmDb": "278"})
if provider != "tmdb" || id != "278" {
@@ -37,3 +61,99 @@ func TestMovieProviderPrefersTMDBAndFallsBackToIMDb(t *testing.T) {
t.Fatalf("fallback provider = %q %q", provider, id)
}
}
func TestRatingsNeedRefreshServesStoredValuesForAMonthAndRetriesEmptyOnesSooner(t *testing.T) {
now := time.Date(2026, 8, 5, 12, 0, 0, 0, time.UTC)
scored := []mdblist.Rating{{Source: "imdb", Value: 8.2}}
if ratingsNeedRefresh(scored, now.Add(-29*24*time.Hour), now) {
t.Fatal("a month-old score should still be considered current")
}
if !ratingsNeedRefresh(scored, now.Add(-31*24*time.Hour), now) {
t.Fatal("an expired score should be refreshed")
}
// Nothing found is worth asking about again sooner: a new release gains scores.
if ratingsNeedRefresh(nil, now.Add(-2*24*time.Hour), now) {
t.Fatal("a recent empty answer should be kept")
}
if !ratingsNeedRefresh(nil, now.Add(-4*24*time.Hour), now) {
t.Fatal("an old empty answer should be retried")
}
if !ratingsNeedRefresh(scored, time.Time{}, now) {
t.Fatal("an unknown fetch time should be refreshed")
}
}
func TestRatingKeyForRatesEpisodesAsTheirSeriesAndPrefersTMDB(t *testing.T) {
key, ok := ratingKeyFor("Episode", map[string]string{"Tmdb": "1396", "Imdb": "tt0903747"})
if !ok || key != (store.RatingKey{MediaType: "show", Provider: "tmdb", ProviderID: "1396"}) {
t.Fatalf("episode key = %+v %v", key, ok)
}
key, ok = ratingKeyFor("Movie", map[string]string{"IMDB": "tt0111161"})
if !ok || key != (store.RatingKey{MediaType: "movie", Provider: "imdb", ProviderID: "tt0111161"}) {
t.Fatalf("movie key = %+v %v", key, ok)
}
if _, ok := ratingKeyFor("Movie", map[string]string{"Tvdb": "1234"}); ok {
t.Fatal("an item MDBList cannot be asked about must not produce a key")
}
if _, ok := ratingKeyFor("BoxSet", map[string]string{"Tmdb": "9"}); ok {
t.Fatal("only films and shows are rated")
}
}
func TestInjectItemRatingsKeepsEveryOtherFieldOfEmbysPayload(t *testing.T) {
raw := json.RawMessage(`{"Id":"42","Name":"Arrival","UnknownToThisBuild":{"a":1}}`)
out := injectItemRatings(raw, []movieRating{
{Source: "imdb", Name: "IMDb", Score: "7.9", Scale: "/10"},
})
var decoded map[string]json.RawMessage
if err := json.Unmarshal(out, &decoded); err != nil {
t.Fatalf("decode: %v", err)
}
if string(decoded["UnknownToThisBuild"]) != `{"a":1}` || string(decoded["Name"]) != `"Arrival"` {
t.Fatalf("payload lost fields: %s", out)
}
var ratings []movieRating
if json.Unmarshal(decoded[ratingsItemField], &ratings) != nil || len(ratings) != 1 ||
ratings[0].Name != "IMDb" {
t.Fatalf("ratings = %s", decoded[ratingsItemField])
}
// Nothing to say is said by saying nothing: an empty list must not add the field.
if got := injectItemRatings(raw, nil); string(got) != string(raw) {
t.Fatalf("empty ratings changed the payload: %s", got)
}
}
func TestItemIDsInDeduplicatesAcrossRowsAndStopsAtTheLimit(t *testing.T) {
item := func(id string) json.RawMessage { return json.RawMessage(`{"Id":"` + id + `"}`) }
ids := itemIDsIn([][]json.RawMessage{
{item("a"), item("b")},
{item("b"), item("c"), json.RawMessage(`{"Name":"no id"}`)},
}, 10)
if len(ids) != 3 || ids[0] != "a" || ids[2] != "c" {
t.Fatalf("ids = %v", ids)
}
if capped := itemIDsIn([][]json.RawMessage{{item("a"), item("b"), item("c")}}, 2); len(capped) != 2 {
t.Fatalf("capped = %v", capped)
}
}
func TestClaimRatingsBudgetLimitsTheDayAndHonoursABackoff(t *testing.T) {
s := &Server{}
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
for spent := 0; spent < ratingsWarmDailyBudget; spent++ {
if !s.claimRatingsBudget(now) {
t.Fatalf("budget refused at %d", spent)
}
}
if s.claimRatingsBudget(now) {
t.Fatal("the daily budget should be spent")
}
// A new day restores it.
if !s.claimRatingsBudget(now.Add(25 * time.Hour)) {
t.Fatal("a new window should restore the budget")
}
s.blockRatingsWarming(now.Add(26 * time.Hour))
if s.claimRatingsBudget(now.Add(25*time.Hour + time.Minute)) {
t.Fatal("a refused provider should stop the warmer entirely")
}
}
+10 -6
View File
@@ -64,7 +64,7 @@ func (s *Server) buildRecommendations(ctx context.Context, sess store.Session) (
// rebuild on every single home load.
if raw, err := json.Marshal(rows); err == nil {
if err := s.cache.Set(ctx, cache.RecommendationsKey(sess.EmbyUserID), raw, s.cfg.RecommendTTL); err != nil {
s.log.Warn("recommendation cache write failed", "error", err)
s.loggerFor(ctx).Warn("recommendation cache write failed", "error", err)
}
}
return rows, nil
@@ -89,10 +89,10 @@ func (s *Server) refreshRecommendationsInBackground(sess store.Session) {
started := time.Now()
rows, err := s.buildRecommendations(ctx, sess)
if err != nil {
s.log.Warn("recommendation build failed", "user", sess.EmbyUserID, "error", err)
s.loggerFor(ctx).Warn("recommendation build failed", "user", sess.EmbyUserID, "error", err)
return
}
s.log.Info("recommendations rebuilt",
s.loggerFor(ctx).Info("recommendations rebuilt",
"user", sess.EmbyUserID, "rows", len(rows), "ms", time.Since(started).Milliseconds())
}()
}
@@ -107,6 +107,7 @@ func (s *Server) handleRecommendations(w http.ResponseWriter, r *http.Request, s
if !forceRefresh {
if rows := s.cachedRecommendations(ctx, sess.EmbyUserID); rows != nil {
w.Header().Set("X-Memby-Cache", "hit")
s.decorateRowRatings(ctx, rows)
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
return
}
@@ -117,10 +118,11 @@ func (s *Server) handleRecommendations(w http.ResponseWriter, r *http.Request, s
rows, err := s.buildRecommendations(buildCtx, sess)
if err != nil {
s.writeUpstreamError(w, err, "could not build recommendations")
s.writeUpstreamError(ctx, w, err, "could not build recommendations")
return
}
w.Header().Set("X-Memby-Cache", "miss")
s.decorateRowRatings(ctx, rows)
writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)})
}
@@ -132,13 +134,14 @@ func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request, sess store
if s.forYou != nil && r.URL.Query().Get("refresh") != "1" {
rows, hit, stale, err := s.forYou.PreparedRows(r.Context(), sess, minutes)
if err != nil {
s.log.Warn("prepared For You read failed; using live fallback",
s.loggerFor(r.Context()).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")
s.decorateRowRatings(r.Context(), rows)
if stale {
s.forYou.MarkDirty(r.Context(), sess)
s.forYou.RefreshAsync(sess, false)
@@ -160,13 +163,14 @@ func (s *Server) handleForYou(w http.ResponseWriter, r *http.Request, sess store
recommend.ForYouOptions{AvailableMinutes: minutes},
)
if err != nil {
s.writeUpstreamError(w, err, "could not build For You recommendations")
s.writeUpstreamError(r.Context(), 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")
s.decorateRowRatings(r.Context(), rows)
writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)})
}
+80 -16
View File
@@ -2,9 +2,12 @@ package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -33,42 +36,49 @@ func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request, sess stor
writeError(w, http.StatusBadRequest, "item id is required")
return
}
key := cache.UserKey(sess.EmbyUserID, "related:v1:"+itemID)
key := cache.UserKey(sess.EmbyUserID, "related:v2:"+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")
item, ok := s.relatedSubject(w, r, sess, itemID)
if !ok {
return
}
reasons, related, err := s.recommender.RelatedTo(
ctx, credentials(sess), decoded[0], relatedRowSize,
)
reasons, related, err := s.recommender.RelatedTo(ctx, credentials(sess), item, relatedRowSize)
if err != nil {
s.writeUpstreamError(w, err, "could not load related titles")
// RelatedTo degrades rather than failing, so the only error it returns is the
// viewer having navigated on. That is the television saving work, not a fault.
if expectedClientDisconnect(r, err) {
s.loggerFor(ctx).Debug("related request abandoned", "item", itemID)
return
}
s.writeUpstreamError(ctx, w, err, "could not load related titles")
return
}
items := nonNilRaws(recommend.Raws(related))
s.decorateItemRatings(ctx, items)
body, err := json.Marshal(relatedResponse{
Reasons: nonNilStrings(reasons),
Items: nonNilRaws(recommend.Raws(related)),
Items: items,
})
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)
// An empty carousel is cached briefly rather than for the item lifetime: it usually
// means something upstream was unwell, and the ten-minute answer would otherwise
// outlive the minute of trouble that produced it.
ttl := s.cfg.ItemTTL
if len(items) == 0 {
ttl = relatedEmptyTTL
}
if err := s.cache.Set(ctx, key, body, ttl); err != nil {
s.loggerFor(ctx).Warn("related cache write failed", "error", err)
}
w.Header().Set("X-Memby-Cache", "miss")
writeRaw(w, http.StatusOK, body)
@@ -78,6 +88,60 @@ func (s *Server) handleRelated(w http.ResponseWriter, r *http.Request, sess stor
// twelfth card has stopped looking for something like this one.
const relatedRowSize = 12
// relatedEmptyTTL keeps a carousel-less answer only long enough to stop a page that is
// being scrolled past from asking twice.
const relatedEmptyTTL = time.Minute
// relatedSubject resolves the title the page is about, and is the first place this
// endpoint refuses to fail: the imported catalogue holds the same payload Emby would
// have returned, so a detail page opened while Emby is unwell still gets its genres —
// which is all `genreNeighbours` needs to fill the strip from Postgres alone.
//
// It writes the response itself when there is nothing to answer with, and reports
// whether the caller should carry on.
func (s *Server) relatedSubject(
w http.ResponseWriter, r *http.Request, sess store.Session, itemID string,
) (recommend.Item, bool) {
ctx := r.Context()
raw, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsRelated)
if err == nil {
if decoded := recommend.Decode([]json.RawMessage{raw}); len(decoded) > 0 {
return decoded[0], true
}
}
// A rejected session is the viewer's problem to act on and must reach the TV as a
// 401; an abandoned request is nobody's. Neither is worth a catalogue read.
if err != nil {
if expectedClientDisconnect(r, err) {
s.loggerFor(ctx).Debug("related request abandoned", "item", itemID)
return recommend.Item{}, false
}
var apiErr *emby.APIError
if errors.As(err, &apiErr) &&
(apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden) {
writeError(w, http.StatusUnauthorized, "emby rejected the session")
return recommend.Item{}, false
}
}
if raws, storeErr := s.store.LibraryItemsByID(ctx, []string{itemID}); storeErr == nil {
if decoded := recommend.Decode(raws); len(decoded) > 0 {
s.loggerFor(ctx).Warn(
"related item served from the imported catalogue", "item", itemID, "error", err,
)
return decoded[0], true
}
}
if err == nil {
writeError(w, http.StatusNotFound, "item not found")
return recommend.Item{}, false
}
s.writeUpstreamError(ctx, w, err, "could not load the item")
return recommend.Item{}, false
}
func nonNilRaws(values []json.RawMessage) []json.RawMessage {
if values == nil {
return []json.RawMessage{}
+8 -8
View File
@@ -82,13 +82,13 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
defer source.Close()
if err := os.MkdirAll(s.cfg.ReleaseDir, 0o750); err != nil {
s.log.Error("release directory unavailable", "error", err)
s.loggerFor(r.Context()).Error("release directory unavailable", "error", err)
writeError(w, http.StatusInternalServerError, "release storage unavailable")
return
}
temp, err := os.CreateTemp(s.cfg.ReleaseDir, ".memby-upload-*")
if err != nil {
s.log.Error("release temp file failed", "error", err)
s.loggerFor(r.Context()).Error("release temp file failed", "error", err)
writeError(w, http.StatusInternalServerError, "release storage unavailable")
return
}
@@ -145,19 +145,19 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
}
newFile = false
} else if !os.IsNotExist(hashErr) {
s.log.Error("existing release could not be verified", "error", hashErr)
s.loggerFor(r.Context()).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)
s.loggerFor(r.Context()).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)
s.loggerFor(r.Context()).Warn("release permissions could not be tightened", "error", err)
}
policy := appupdate.Policy{
@@ -178,17 +178,17 @@ func (s *Server) handleReleasePublish(w http.ResponseWriter, r *http.Request) {
if newFile {
_ = os.Remove(destination)
}
s.log.Error("release policy write failed", "error", err)
s.loggerFor(r.Context()).Error("release policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be saved")
return
}
if err := s.LoadUpdatePolicy(r.Context()); err != nil {
s.log.Error("release policy reload failed", "error", err)
s.loggerFor(r.Context()).Error("release policy reload failed", "error", err)
writeError(w, http.StatusInternalServerError, "APK stored but update policy could not be loaded")
return
}
s.log.Info("release published", "version", version, "mandatory", mandatory,
s.loggerFor(r.Context()).Info("release published", "version", version, "mandatory", mandatory,
"bytes", written, "file", filename)
writeJSON(w, http.StatusCreated, s.updatePolicy.get())
}
+14 -11
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -34,7 +35,7 @@ func (s *Server) requestAllowed(r *http.Request, sess store.Session) bool {
}
policy, err := s.store.RequestPolicy(r.Context())
if err != nil {
s.log.Error("request policy read failed", "error", err)
s.loggerFor(r.Context()).Error("request policy read failed", "error", err)
return false
}
return policy.Allows(sess.EmbyUserID)
@@ -60,7 +61,7 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
defer wg.Done()
movies, err := s.radarr.Lookup(r.Context(), term)
if err != nil {
s.log.Warn("Radarr request lookup failed", "error", err)
s.loggerFor(r.Context()).Warn("Radarr request lookup failed", "error", err)
return
}
for _, movie := range movies {
@@ -81,7 +82,7 @@ func (s *Server) handleRequestLookup(w http.ResponseWriter, r *http.Request, ses
defer wg.Done()
series, err := s.sonarr.Lookup(r.Context(), term)
if err != nil {
s.log.Warn("Sonarr request lookup failed", "error", err)
s.loggerFor(r.Context()).Warn("Sonarr request lookup failed", "error", err)
return
}
for _, show := range series {
@@ -171,7 +172,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
}
movies, err := s.radarr.Lookup(r.Context(), "tmdb:"+strconv.Itoa(req.ForeignID))
if err != nil {
s.writeRequestUpstreamError(w, err, "Radarr lookup failed")
s.writeRequestUpstreamError(r.Context(), w, err, "Radarr lookup failed")
return
}
for _, movie := range movies {
@@ -184,10 +185,10 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
}
added, err := s.radarr.AddUnmonitored(r.Context(), movie)
if err != nil {
s.writeRequestUpstreamError(w, err, "could not add movie to Radarr")
s.writeRequestUpstreamError(r.Context(), w, err, "could not add movie to Radarr")
return
}
s.log.Info("media requested", "user", sess.Username, "type", "movie", "title", added.Title)
s.loggerFor(r.Context()).Info("media requested", "user", sess.Username, "type", "movie", "title", added.Title)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
}
@@ -198,7 +199,7 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
}
series, err := s.sonarr.Lookup(r.Context(), "tvdb:"+strconv.Itoa(req.ForeignID))
if err != nil {
s.writeRequestUpstreamError(w, err, "Sonarr lookup failed")
s.writeRequestUpstreamError(r.Context(), w, err, "Sonarr lookup failed")
return
}
for _, show := range series {
@@ -211,10 +212,10 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
}
added, err := s.sonarr.AddUnmonitored(r.Context(), show)
if err != nil {
s.writeRequestUpstreamError(w, err, "could not add series to Sonarr")
s.writeRequestUpstreamError(r.Context(), w, err, "could not add series to Sonarr")
return
}
s.log.Info("media requested", "user", sess.Username, "type", "series", "title", added.Title)
s.loggerFor(r.Context()).Info("media requested", "user", sess.Username, "type", "series", "title", added.Title)
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
return
}
@@ -225,7 +226,9 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
writeError(w, http.StatusNotFound, "title was not found")
}
func (s *Server) writeRequestUpstreamError(w http.ResponseWriter, err error, message string) {
func (s *Server) writeRequestUpstreamError(
ctx context.Context, w http.ResponseWriter, err error, message string,
) {
var radarrErr *radarr.APIError
var sonarrErr *sonarr.APIError
if (errors.As(err, &radarrErr) && radarrErr.StatusCode == http.StatusBadRequest) ||
@@ -233,6 +236,6 @@ func (s *Server) writeRequestUpstreamError(w http.ResponseWriter, err error, mes
writeError(w, http.StatusConflict, "the title could not be added; it may already exist")
return
}
s.log.Error(message, "error", err)
s.loggerFor(ctx).Error(message, "error", err)
writeError(w, http.StatusBadGateway, message)
}
+51 -2
View File
@@ -28,8 +28,51 @@ const (
// 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
// A deployment is announced before it happens, and the window is the whole delivery
// mechanism: it must stay open across the image build, which is the several minutes
// during which the gateway is still answering and every open TV will poll at least
// once. It cannot be made to cover the outage itself — the alert list lives in a
// Redis with no persistence and no volume, so the swap takes it with everything else.
// That is why this is published early rather than at the swap.
deploymentAlertWindow = 20 * time.Minute
)
// Wording is fixed here rather than taken from the caller: the deployment script is not a
// place for on-screen copy, and a notice that reads differently each release is one
// viewers have to read twice.
const (
deploymentAlertTitle = "Memby server in deployment mode"
deploymentAlertMessage = "New update coming will be available soon."
)
// AnnounceDeployment tells every open TV that the gateway is about to be replaced.
//
// It is published by the deploying operator *before* the old container stops — in fact
// before the image is even built — because that is the only time there is anything left
// to say it with. Once the stack is down the status poll fails, and a television shows a
// connection error with no idea that somebody meant it to happen; publishing at the swap
// would be too late for the same reason it is needed.
//
// Nothing here waits for the deployment to finish. The gateway that publishes this is the
// one being retired; the one that comes back has no memory of having said it.
func (s *Server) AnnounceDeployment(ctx context.Context) {
s.publishAlert(ctx, deploymentAlert(time.Now().UTC()), deploymentAlertWindow)
}
func deploymentAlert(now time.Time) clientAlert {
return clientAlert{
// Keyed on the second, so a redeployment ten minutes later is a second notice
// rather than one the fleet has already dismissed as seen.
ID: fmt.Sprintf("deploy:%d", now.Unix()),
Kind: alertKindDeploying,
Label: "SERVER UPDATING",
Title: deploymentAlertTitle,
Message: deploymentAlertMessage,
AiredAt: now.Format(time.RFC3339),
}
}
// AnnounceLibrarySync tells every TV that the catalogue moved.
//
// Only a run that actually changed something is announced: the import is scheduled, so
@@ -80,6 +123,10 @@ func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Durati
// open with a banner about a state nobody has seen change.
reachable := true
failures := 0
// The same probe feeds the live state on /v1/status, so one request to Emby answers
// both "did this just change" and "is it working right now". Declared before the
// first tick so a client asking during the opening minute learns the retry interval.
s.embyHealth.begin(interval, time.Now().UTC())
for {
select {
case <-ctx.Done():
@@ -88,18 +135,20 @@ func (s *Server) WatchEmbyReachability(ctx context.Context, interval time.Durati
probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := s.emby.Ping(probeCtx)
cancel()
s.embyHealth.record(err == nil, time.Now().UTC())
if err != nil {
failures++
if reachable && failures >= embyFailureThreshold {
reachable = false
s.log.Warn("emby unreachable, announcing", "error", err)
s.log.Warn("emby unreachable, announcing",
"component", "emby-health", "failures", failures, "error", err)
s.publishAlert(ctx, s.reachabilityAlert(false), reachabilityAlertWindow)
}
continue
}
if !reachable {
s.log.Info("emby reachable again, announcing")
s.log.Info("emby reachable again, announcing", "component", "emby-health")
s.publishAlert(ctx, s.reachabilityAlert(true), reachabilityAlertWindow)
}
reachable = true
+35
View File
@@ -3,6 +3,7 @@ package api
import (
"strings"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/library"
)
@@ -42,6 +43,40 @@ func TestReachabilityAlertsAreDistinctAndSelfExplaining(t *testing.T) {
}
}
// The deployment notice is published by the gateway that is about to stop, and read by a
// television that may not hear anything else for several minutes. Everything it needs to
// render, and to be ordered against the news around it, has to travel with it.
func TestDeploymentAlertIsSelfContained(t *testing.T) {
at := time.Date(2026, 8, 5, 21, 30, 15, 0, time.UTC)
alert := deploymentAlert(at)
if alert.Kind != alertKindDeploying {
t.Errorf("kind = %q, want %q", alert.Kind, alertKindDeploying)
}
if alert.Label == "" || alert.Title == "" || alert.Message == "" {
t.Errorf("alert has nothing to render: %+v", alert)
}
// mergeAlerts orders by this, and an alert with no timestamp sorts last — behind the
// library and reachability news it is most likely to arrive beside.
if alert.AiredAt != "2026-08-05T21:30:15Z" {
t.Errorf("airedAt = %q, want the publishing moment", alert.AiredAt)
}
// A second deployment must be a second banner: TVs dedupe by id for the life of the
// install, so reusing one would silently skip the notice on every set that saw it.
if later := deploymentAlert(at.Add(time.Minute)); later.ID == alert.ID {
t.Errorf("two deployments share id %q", alert.ID)
}
}
// The window has to outlast the part of a deployment where nothing can be published: the
// image build, the swap, and the gap before the new container answers.
func TestDeploymentAlertOutlivesTheOutage(t *testing.T) {
if deploymentAlertWindow < 10*time.Minute {
t.Errorf("window %s is shorter than a build and a swap", deploymentAlertWindow)
}
}
// 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) {
+87 -9
View File
@@ -14,7 +14,9 @@ import (
"github.com/ponzischeme89/memby/server/internal/store"
)
const sonarrCalendarCachePrefix = "sonarr:calendar:v2:"
// Bump when the authored row contract changes so an older ordering cannot survive a
// deployment until the previous daily cache expires.
const sonarrCalendarCachePrefix = "sonarr:calendar:v4:"
const sonarrPrerollCachePrefix = "sonarr:preroll:v2:"
const sonarrScheduleDays = 5
@@ -41,7 +43,7 @@ func (s *Server) handlePreroll(w http.ResponseWriter, r *http.Request, _ store.S
schedule, err := s.sonarrPrerollSchedule(r.Context())
if err != nil {
// Pre-roll is decorative and must never become a playback dependency.
s.log.Warn("preroll schedule unavailable", "error", err)
s.loggerFor(r.Context()).Warn("preroll schedule unavailable", "error", err)
writeJSON(w, http.StatusOK, emptyPrerollSchedule())
return
}
@@ -79,7 +81,7 @@ func (s *Server) sonarrPrerollSchedule(ctx context.Context) (prerollScheduleResp
schedule := buildPrerollSchedule(episodes, now, location)
if raw, err := json.Marshal(schedule); err == nil {
if cacheErr := s.cache.Set(ctx, key, raw, s.cfg.SonarrTTL); cacheErr != nil {
s.log.Warn("preroll schedule cache write failed", "error", cacheErr)
s.loggerFor(ctx).Warn("preroll schedule cache write failed", "error", cacheErr)
}
}
return schedule, nil
@@ -186,7 +188,70 @@ type sonarrScheduleItem struct {
MembyAirLabel string `json:"MembyAirLabel"`
MembyAvailability string `json:"MembyAvailability"`
MembyAvailabilityText string `json:"MembyAvailabilityText"`
MembyPlayable bool `json:"MembyPlayable"`
// Sonarr's lifecycle for the *show*, distinct from this episode's availability: one
// says whether more episodes are coming at all, the other whether this one is here yet.
MembyLifecycle string `json:"MembyLifecycle,omitempty"`
MembyLifecycleText string `json:"MembyLifecycleText,omitempty"`
MembyPlayable bool `json:"MembyPlayable"`
// The Emby series this episode belongs to, when the library holds it. It is what lets
// the card open the show's own page; a show Sonarr follows but Emby has never imported
// simply carries none, and the card stays informational as it always was.
MembySeriesItemID string `json:"MembySeriesItemId,omitempty"`
}
// seriesIndex resolves a Sonarr series title and year onto an Emby item id. It is a map
// rather than a store call per episode: one row can carry a dozen episodes of the same
// show, and the answer is the same for all of them.
type seriesIndex map[string]string
// embySeriesIndex builds the lookup for one row build. A failure is not fatal — the row
// is about what is *about* to air, and losing the link only costs the card its page.
func (s *Server) embySeriesIndex(ctx context.Context) seriesIndex {
if s.store == nil {
return nil
}
refs, err := s.store.SeriesRefs(ctx)
if err != nil {
s.loggerFor(ctx).Warn("emby series index unavailable for schedule row", "error", err)
return nil
}
index := make(seriesIndex, len(refs)*2)
for _, ref := range refs {
key := normalizedShowTitle(ref.Name)
if key == "" {
continue
}
// The year-qualified key is written first and never overwritten, so a remake
// cannot claim the original's page when both are in the library.
if ref.Year > 0 {
if _, seen := index[seriesIndexKey(key, ref.Year)]; !seen {
index[seriesIndexKey(key, ref.Year)] = ref.ID
}
}
if _, seen := index[key]; !seen {
index[key] = ref.ID
}
}
return index
}
func seriesIndexKey(normalizedTitle string, year int) string {
return fmt.Sprintf("%s|%d", normalizedTitle, year)
}
// lookup prefers the title/year pair and falls back to the title alone, because Sonarr
// and Emby disagree about a show's year more often than they disagree about its name.
func (index seriesIndex) lookup(title string, year int) string {
key := normalizedShowTitle(title)
if key == "" || len(index) == 0 {
return ""
}
if year > 0 {
if id, ok := index[seriesIndexKey(key, year)]; ok {
return id
}
}
return index[key]
}
func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, error) {
@@ -221,14 +286,14 @@ func (s *Server) sonarrAiringTodayRow(ctx context.Context) (*recommend.Row, erro
if err != nil {
return nil, err
}
row, err := buildSonarrRow(episodes, now, location)
row, err := buildSonarrRow(episodes, now, location, s.embySeriesIndex(ctx))
if err != nil {
return nil, err
}
body, err := json.Marshal(row)
if err == nil {
if cacheErr := s.cache.Set(ctx, cacheKey, body, s.cfg.SonarrTTL); cacheErr != nil {
s.log.Warn("sonarr calendar cache write failed", "error", cacheErr)
s.loggerFor(ctx).Warn("sonarr calendar cache write failed", "error", cacheErr)
}
}
return row, nil
@@ -246,7 +311,12 @@ func (s *Server) cachedSonarrRow(ctx context.Context, key string) *recommend.Row
return &row
}
func buildSonarrRow(episodes []sonarr.Episode, now time.Time, location *time.Location) (*recommend.Row, error) {
func buildSonarrRow(
episodes []sonarr.Episode,
now time.Time,
location *time.Location,
series seriesIndex,
) (*recommend.Row, error) {
sort.SliceStable(episodes, func(i, j int) bool {
if episodes[i].AirDateUTC == nil {
return false
@@ -268,7 +338,7 @@ func buildSonarrRow(episodes []sonarr.Episode, now time.Time, location *time.Loc
if localAirTime.Before(dayStart) || !localAirTime.Before(windowEnd) {
continue
}
item := toSonarrScheduleItem(episode, now, location)
item := toSonarrScheduleItem(episode, now, location, series)
raw, err := json.Marshal(item)
if err != nil {
return nil, err
@@ -283,7 +353,12 @@ func buildSonarrRow(episodes []sonarr.Episode, now time.Time, location *time.Loc
}, nil
}
func toSonarrScheduleItem(episode sonarr.Episode, now time.Time, location *time.Location) sonarrScheduleItem {
func toSonarrScheduleItem(
episode sonarr.Episode,
now time.Time,
location *time.Location,
series seriesIndex,
) sonarrScheduleItem {
seriesID := episode.SeriesID
if episode.Series.ID > 0 {
seriesID = episode.Series.ID
@@ -303,6 +378,9 @@ func toSonarrScheduleItem(episode sonarr.Episode, now time.Time, location *time.
MembyEpisodeCode: fmt.Sprintf("S%02dE%02d", episode.SeasonNumber, episode.EpisodeNumber),
MembyPlayable: false,
}
item.MembySeriesItemID = series.lookup(episode.Series.Title, episode.Series.Year)
lifecycle := seriesLifecycleTag(episode.Series.Status)
item.MembyLifecycle, item.MembyLifecycleText = lifecycle.Status, lifecycle.Label
if hasCover(episode.Series.Images, "poster") {
item.ImageTags["Primary"] = "sonarr"
}
+50 -4
View File
@@ -30,7 +30,7 @@ func TestBuildSonarrRowIncludesScheduleAndAddedState(t *testing.T) {
{CoverType: "fanart"},
},
},
}}, time.Date(2026, 7, 27, 21, 0, 0, 0, location), location)
}}, time.Date(2026, 7, 27, 21, 0, 0, 0, location), location, nil)
if err != nil {
t.Fatal(err)
}
@@ -52,6 +52,50 @@ func TestBuildSonarrRowIncludesScheduleAndAddedState(t *testing.T) {
}
}
func TestBuildSonarrRowLinksTheEmbySeries(t *testing.T) {
location := time.UTC
now := time.Date(2026, 7, 27, 8, 0, 0, 0, location)
air := time.Date(2026, 7, 27, 20, 0, 0, 0, location)
index := seriesIndex{
"northbound": "emby-old",
"northbound|2024": "emby-2024",
"harbour": "emby-harbour",
}
episode := func(title string, year int) sonarr.Episode {
utc := air
return sonarr.Episode{
ID: 1, SeriesID: 1, SeasonNumber: 1, EpisodeNumber: 1, AirDateUTC: &utc,
Series: sonarr.Series{ID: 1, Title: title, Year: year},
}
}
cases := map[string]struct {
episode sonarr.Episode
want string
}{
// The year decides between a remake and its original.
"year qualified": {episode("Northbound", 2024), "emby-2024"},
// Sonarr and Emby disagree about a year more often than about a name.
"year mismatch falls back to the title": {episode("Northbound", 1999), "emby-old"},
"punctuation and case are ignored": {episode("Harbour!", 0), "emby-harbour"},
"not in the library": {episode("Unimported", 2026), ""},
}
for name, testCase := range cases {
t.Run(name, func(t *testing.T) {
row, err := buildSonarrRow([]sonarr.Episode{testCase.episode}, now, location, index)
if err != nil {
t.Fatal(err)
}
var item sonarrScheduleItem
if err := json.Unmarshal(row.Items[0], &item); err != nil {
t.Fatal(err)
}
if item.MembySeriesItemID != testCase.want {
t.Fatalf("series id = %q, want %q", item.MembySeriesItemID, testCase.want)
}
})
}
}
func TestBuildPrerollScheduleSplitsTodayAndWeek(t *testing.T) {
location := time.FixedZone("NZST", 12*60*60)
now := time.Date(2026, 7, 28, 10, 0, 0, 0, location)
@@ -93,11 +137,13 @@ func TestBuildSonarrRowCoversFiveDaysAndUsesRelativeAirLabels(t *testing.T) {
}
}
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)),
// Sonarr's response order is not part of its contract. Deliberately put the
// later episode first to ensure the row is authored chronologically.
episode(3, time.Date(2026, 8, 2, 16, 0, 0, 0, location)),
episode(2, time.Date(2026, 7, 30, 16, 0, 0, 0, location)),
episode(1, time.Date(2026, 7, 29, 16, 0, 0, 0, location)),
episode(4, time.Date(2026, 8, 3, 16, 0, 0, 0, location)),
}, now, location)
}, now, location, nil)
if err != nil {
t.Fatal(err)
}
+643
View File
@@ -0,0 +1,643 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/bazarr"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
)
// Fetching a subtitle a title does not have.
//
// The whole feature rests on one property of Bazarr: it writes the subtitle file beside
// the media file. So the gateway never stores a subtitle, never serves one, and never
// learns a provider's credentials — it asks Bazarr to fetch, asks Emby to look again, and
// the track then arrives down the same PlaybackInfo path as an embedded one. That is why
// `playableSubtitle` needed no new shape and the player's existing selection rule works on
// a downloaded track with no special case.
//
// The hard part is not the download, it is the identity. Bazarr keys everything on the
// *arr's id (`radarrid` for a film, Sonarr's `episodeid` for an episode) and Emby knows
// nothing about either, so an Emby item has to be matched onto one by title, year and —
// for an episode — season and episode number. That matching is pure and unit-tested
// (`bazarrMovieFor`, `bazarrEpisodeFor`), because it is where a wrong answer is worst: a
// mismatch downloads a subtitle for the wrong film and writes it next to this one.
const (
bazarrMoviesCacheKey = "bazarr:movies"
bazarrSeriesCacheKey = "bazarr:series"
bazarrEpisodesCacheKey = "bazarr:episodes:"
// maxSubtitleResults caps what a television is shown. A manual search can return
// dozens of near-identical releases, and a D-pad list is not the place to read them.
maxSubtitleResults = 12
// embyRefreshSettleDelay is how long Emby is given to notice the new file before the
// gateway re-reads the item's streams. A refresh is asynchronous, so returning
// immediately would reliably report the track as missing on the one request that is
// certain to be looking for it.
embyRefreshSettleDelay = 1500 * time.Millisecond
)
// subtitleCandidate is one row a viewer can choose from.
//
// Token is Bazarr's opaque provider handle and it round-trips through the television
// untouched — nothing on either side parses it. Keeping it on the wire rather than in a
// server-side cache means a viewer who reads the list slowly cannot have their choice
// expire underneath them, which on a set being operated by remote control is the more
// likely failure of the two.
type subtitleCandidate struct {
Token string `json:"token"`
Language string `json:"language"`
LanguageLabel string `json:"languageLabel"`
Provider string `json:"provider"`
Score int `json:"score"`
Forced bool `json:"forced"`
HearingImpaired bool `json:"hearingImpaired"`
OriginalFormat bool `json:"originalFormat"`
// Label is what the drop-up prints. It is composed here rather than on the TV so an
// older app renders a new wording correctly, the same reason alert labels are the
// gateway's.
Label string `json:"label"`
}
type subtitleSearchResponse struct {
Results []subtitleCandidate `json:"results"`
// Message is what to say when there are none. A search that reached the providers and
// found nothing is a different answer from one that could not identify the title, and
// a viewer standing in front of the set deserves to be told which.
Message string `json:"message,omitempty"`
}
type subtitleDownloadRequest struct {
// The candidate exactly as it was handed out. Only Token, Language, Provider and the
// three flags are read; anything else the client echoes is ignored.
Candidate subtitleCandidate `json:"candidate"`
}
type subtitleDownloadResponse struct {
Message string `json:"message"`
// The item's subtitle tracks re-read from Emby after the refresh, so the player can
// swap its media item and turn the new track on without a second round trip.
Subtitles []playableSubtitle `json:"subtitles"`
// Which of them is the one that was just fetched, when it can be identified. Empty
// means "it is in the list somewhere" — the client falls back to its own rule rather
// than guessing, exactly as it does for the server's ordinary selection.
SelectedSubtitleID string `json:"selectedSubtitleId,omitempty"`
MediaSourceID string `json:"mediaSourceId"`
PlaySessionID string `json:"playSessionId"`
URL string `json:"url"`
}
// subtitleDownloadAvailable is the one thing the television needs to know: whether to
// offer the option at all. Both halves matter — an operator can turn the feature off on a
// deployment that has Bazarr, and a deployment without Bazarr must never show a row that
// cannot do anything.
func (s *Server) subtitleDownloadAvailable(ctx context.Context) bool {
return s.bazarr != nil && s.featureEnabled(ctx, featureSubtitleDownload)
}
func (s *Server) handleSubtitleSearch(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
}
if !s.subtitleDownloadAvailable(ctx) {
writeError(w, http.StatusNotFound, "subtitle downloads are not available")
return
}
target, err := s.resolveBazarrTarget(ctx, credentials(sess), itemID)
if err != nil {
s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err)
writeJSON(w, http.StatusOK, subtitleSearchResponse{
Results: []subtitleCandidate{},
Message: "Memby could not work out which title this is.",
})
return
}
found, err := s.searchBazarr(ctx, target)
if err != nil {
s.loggerFor(ctx).Warn("subtitle search failed",
"item", itemID, "title", target.Title, "error", err,
)
writeJSON(w, http.StatusOK, subtitleSearchResponse{
Results: []subtitleCandidate{},
Message: "The subtitle service did not answer.",
})
return
}
language := strings.TrimSpace(r.URL.Query().Get("language"))
if language == "" {
_, language = s.subtitlePreferenceFor(ctx, sess)
}
results := rankSubtitleCandidates(found, language)
s.loggerFor(ctx).Info("subtitle search",
"title", target.Title,
"item", itemID,
"language", clientLogValue(language),
"found", len(found),
"offered", len(results),
)
response := subtitleSearchResponse{Results: results}
if len(results) == 0 {
response.Message = "No subtitles were found for this release."
}
writeJSON(w, http.StatusOK, response)
}
func (s *Server) handleSubtitleDownload(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
}
if !s.subtitleDownloadAvailable(ctx) {
writeError(w, http.StatusNotFound, "subtitle downloads are not available")
return
}
var request subtitleDownloadRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if strings.TrimSpace(request.Candidate.Token) == "" {
writeError(w, http.StatusBadRequest, "a subtitle is required")
return
}
cred := credentials(sess)
target, err := s.resolveBazarrTarget(ctx, cred, itemID)
if err != nil {
s.loggerFor(ctx).Warn("subtitle target lookup failed", "item", itemID, "error", err)
writeError(w, http.StatusNotFound, "Memby could not work out which title this is")
return
}
subtitle := bazarr.Subtitle{
Language: request.Candidate.Language,
Provider: request.Candidate.Provider,
Token: request.Candidate.Token,
Forced: request.Candidate.Forced,
HearingImpaired: request.Candidate.HearingImpaired,
OriginalFormat: request.Candidate.OriginalFormat,
}
if target.EpisodeID > 0 {
err = s.bazarr.DownloadEpisode(ctx, target.SeriesID, target.EpisodeID, subtitle)
} else {
err = s.bazarr.DownloadMovie(ctx, target.RadarrID, subtitle)
}
if err != nil {
s.loggerFor(ctx).Warn("subtitle download failed",
"title", target.Title, "item", itemID,
"provider", clientLogValue(subtitle.Provider), "error", err,
)
writeError(w, http.StatusBadGateway, "the subtitle could not be downloaded")
return
}
// Bazarr has written the file; Emby does not know it exists. Refreshing is what makes
// the track appear, and it is best-effort: if it fails the file is still on disk and
// the next ordinary scan picks it up, so the viewer is told to try again rather than
// told the download failed when it did not.
if refreshErr := s.emby.RefreshItem(ctx, cred, itemID); refreshErr != nil {
s.loggerFor(ctx).Warn("emby refresh after subtitle download failed",
"item", itemID, "error", refreshErr,
)
}
select {
case <-time.After(embyRefreshSettleDelay):
case <-ctx.Done():
return
}
subtitles, mediaSourceID, playSessionID, negotiatedURL, _ := s.playbackSubtitles(
ctx, cred, itemID, 0, nil, "", false, s.effectivePlaybackCapabilities(ctx, sess),
)
streamURL := s.emby.StreamURL(cred, itemID)
if negotiatedURL != "" {
streamURL = negotiatedURL
}
s.loggerFor(ctx).Info("subtitle downloaded",
"title", target.Title,
"item", itemID,
"language", clientLogValue(subtitle.Language),
"provider", clientLogValue(subtitle.Provider),
"subtitles", len(subtitles),
)
writeJSON(w, http.StatusOK, subtitleDownloadResponse{
Message: downloadedSubtitleMessage(request.Candidate),
Subtitles: subtitles,
SelectedSubtitleID: newestSubtitleID(subtitles, request.Candidate),
MediaSourceID: mediaSourceID,
PlaySessionID: playSessionID,
URL: streamURL,
})
}
// bazarrTarget is an Emby item resolved onto the ids Bazarr keys on. Exactly one of
// RadarrID and EpisodeID is set.
type bazarrTarget struct {
Title string
RadarrID int
SeriesID int
EpisodeID int
}
func (s *Server) searchBazarr(ctx context.Context, target bazarrTarget) ([]bazarr.Subtitle, error) {
if target.EpisodeID > 0 {
return s.bazarr.SearchEpisode(ctx, target.EpisodeID)
}
return s.bazarr.SearchMovie(ctx, target.RadarrID)
}
// resolveBazarrTarget turns an Emby item id into Bazarr's idea of the same thing.
func (s *Server) resolveBazarrTarget(
ctx context.Context, cred emby.Credentials, itemID string,
) (bazarrTarget, error) {
raw, err := s.emby.Item(ctx, cred, itemID, "ProductionYear,SeriesName,ParentIndexNumber,IndexNumber")
if err != nil {
return bazarrTarget{}, err
}
var item struct {
Name string `json:"Name"`
Type string `json:"Type"`
ProductionYear int `json:"ProductionYear"`
SeriesName string `json:"SeriesName"`
ParentIndexNumber int `json:"ParentIndexNumber"`
IndexNumber int `json:"IndexNumber"`
}
if err := json.Unmarshal(raw, &item); err != nil {
return bazarrTarget{}, fmt.Errorf("unreadable item from emby: %w", err)
}
if strings.EqualFold(item.Type, "Episode") {
series, err := s.bazarrSeries(ctx)
if err != nil {
return bazarrTarget{}, err
}
// An episode carries its series' name but not its year, so the year is looked up
// on the series item rather than guessed from the episode's air date — a show that
// ran for years would otherwise match the wrong entry of a remake pair.
matched := bazarrSeriesFor(item.SeriesName, s.seriesYear(ctx, cred, raw), series)
if matched == nil {
return bazarrTarget{}, fmt.Errorf("no bazarr series for %q", item.SeriesName)
}
episodes, err := s.bazarrEpisodes(ctx, matched.SonarrSeriesID)
if err != nil {
return bazarrTarget{}, err
}
episode := bazarrEpisodeFor(episodes, item.ParentIndexNumber, item.IndexNumber)
if episode == nil {
return bazarrTarget{}, fmt.Errorf(
"no bazarr episode for %q S%02dE%02d",
item.SeriesName, item.ParentIndexNumber, item.IndexNumber,
)
}
title := fmt.Sprintf("%s S%02dE%02d", item.SeriesName, episode.Season, episode.Episode)
return bazarrTarget{
Title: title, SeriesID: matched.SonarrSeriesID, EpisodeID: episode.SonarrEpisodeID,
}, nil
}
if !strings.EqualFold(item.Type, "Movie") {
return bazarrTarget{}, fmt.Errorf("subtitles cannot be fetched for a %q", item.Type)
}
movies, err := s.bazarrMovies(ctx)
if err != nil {
return bazarrTarget{}, err
}
matched := bazarrMovieFor(item.Name, item.ProductionYear, movies)
if matched == nil {
return bazarrTarget{}, fmt.Errorf("no bazarr movie for %q", item.Name)
}
return bazarrTarget{Title: item.Name, RadarrID: matched.RadarrID}, nil
}
// seriesYear reads the parent series' production year, which is what disambiguates a
// remake from its original. A failure is not fatal: zero means "match on title alone".
func (s *Server) seriesYear(ctx context.Context, cred emby.Credentials, episode json.RawMessage) int {
var parsed struct {
SeriesID string `json:"SeriesId"`
}
if json.Unmarshal(episode, &parsed) != nil || parsed.SeriesID == "" {
return 0
}
raw, err := s.emby.Item(ctx, cred, parsed.SeriesID, "ProductionYear")
if err != nil {
return 0
}
var series struct {
ProductionYear int `json:"ProductionYear"`
}
if json.Unmarshal(raw, &series) != nil {
return 0
}
return series.ProductionYear
}
// bazarrMovieFor matches an Emby film onto Bazarr's list.
//
// Title alone is not enough — a remake and its original share one — so a year that both
// sides know must agree. A year only one side knows is not treated as a disagreement,
// because Bazarr carries the year as a string and an empty one is common.
func bazarrMovieFor(title string, year int, movies []bazarr.Movie) *bazarr.Movie {
key := normalizedShowTitle(title)
if key == "" {
return nil
}
var fallback *bazarr.Movie
for i := range movies {
candidate := &movies[i]
if normalizedShowTitle(candidate.Title) != key {
continue
}
candidateYear, _ := strconv.Atoi(strings.TrimSpace(candidate.Year))
if year > 0 && candidateYear > 0 && year == candidateYear {
return candidate
}
if fallback == nil && (year == 0 || candidateYear == 0) {
fallback = candidate
}
}
return fallback
}
// bazarrSeriesFor is the same rule for a show. It is deliberately a separate function
// rather than a generic one: the two lists have different field names and a shared helper
// would have to take accessors, which is more machinery than two short loops.
func bazarrSeriesFor(title string, year int, series []bazarr.Series) *bazarr.Series {
key := normalizedShowTitle(title)
if key == "" {
return nil
}
var fallback *bazarr.Series
for i := range series {
candidate := &series[i]
if normalizedShowTitle(candidate.Title) != key {
continue
}
candidateYear, _ := strconv.Atoi(strings.TrimSpace(candidate.Year))
if year > 0 && candidateYear > 0 && year == candidateYear {
return candidate
}
if fallback == nil && (year == 0 || candidateYear == 0) {
fallback = candidate
}
}
return fallback
}
// bazarrEpisodeFor matches on season and episode number, which both Emby and Sonarr agree
// on. Titles are not compared: they differ between the two often enough (translations,
// two-parters named differently) that they would reject correct matches.
//
// Season 0 is a legitimate season — specials — so a zero season number is only rejected
// when the episode number is also missing.
func bazarrEpisodeFor(episodes []bazarr.Episode, season, number int) *bazarr.Episode {
if number <= 0 || season < 0 {
return nil
}
for i := range episodes {
if episodes[i].Season == season && episodes[i].Episode == number {
return &episodes[i]
}
}
return nil
}
// rankSubtitleCandidates orders what the viewer sees and caps the list.
//
// The viewer's language comes first, because it is the only thing they asked for; within
// that, Bazarr's own score decides, because it is the only number on the row that means
// anything on a television. Forced and hearing-impaired tracks sort below plain ones in
// the same language for the reason the selection rule already gives — somebody who chose
// Italian wants the dialogue, not the signs.
func rankSubtitleCandidates(found []bazarr.Subtitle, language string) []subtitleCandidate {
preferred := normalizeSubtitleLanguage(language)
if preferred == subtitleLanguageAuto {
preferred = ""
}
ordered := make([]bazarr.Subtitle, len(found))
copy(ordered, found)
sort.SliceStable(ordered, func(i, j int) bool {
left, right := ordered[i], ordered[j]
leftPreferred := preferred != "" && normalizeSubtitleLanguage(left.Language) == preferred
rightPreferred := preferred != "" && normalizeSubtitleLanguage(right.Language) == preferred
if leftPreferred != rightPreferred {
return leftPreferred
}
if leftVariant, rightVariant := subtitleVariantRank(left), subtitleVariantRank(right); leftVariant != rightVariant {
return leftVariant < rightVariant
}
return left.Score > right.Score
})
if len(ordered) > maxSubtitleResults {
ordered = ordered[:maxSubtitleResults]
}
results := make([]subtitleCandidate, 0, len(ordered))
for _, subtitle := range ordered {
if strings.TrimSpace(subtitle.Token) == "" {
continue
}
results = append(results, subtitleCandidate{
Token: subtitle.Token,
Language: normalizeSubtitleLanguage(subtitle.Language),
LanguageLabel: subtitleLanguageLabel(subtitle.Language),
Provider: subtitle.Provider,
Score: subtitle.Score,
Forced: subtitle.Forced,
HearingImpaired: subtitle.HearingImpaired,
OriginalFormat: subtitle.OriginalFormat,
Label: subtitleCandidateLabel(subtitle),
})
}
return results
}
func subtitleVariantRank(subtitle bazarr.Subtitle) int {
switch {
case subtitle.Forced:
return 2
case subtitle.HearingImpaired:
return 1
default:
return 0
}
}
// subtitleCandidateLabel is what one row says: the language, what kind of track it is, and
// how well Bazarr thinks it matches. The provider is deliberately absent — a viewer has no
// way to prefer one and the name would only crowd the row.
func subtitleCandidateLabel(subtitle bazarr.Subtitle) string {
label := subtitleLanguageLabel(subtitle.Language)
switch {
case subtitle.Forced:
label += " · Forced"
case subtitle.HearingImpaired:
label += " · Hearing impaired"
}
if subtitle.Score > 0 {
label += fmt.Sprintf(" · %d%% match", clampPercent(subtitle.Score))
}
return label
}
func clampPercent(value int) int {
if value < 0 {
return 0
}
if value > 100 {
return 100
}
return value
}
// subtitleLanguageLabel names a language for display, falling back to the code itself
// rather than to a blank when Memby has no entry for it — an unnamed row is worse than an
// unfamiliar one.
func subtitleLanguageLabel(raw string) string {
normalized := normalizeSubtitleLanguage(raw)
for _, language := range subtitleLanguages {
if language.Value == normalized {
return language.Label
}
}
if normalized == "" {
return "Unknown"
}
return strings.ToUpper(normalized)
}
func downloadedSubtitleMessage(candidate subtitleCandidate) string {
label := candidate.LanguageLabel
if strings.TrimSpace(label) == "" {
label = subtitleLanguageLabel(candidate.Language)
}
return label + " subtitles downloaded."
}
// newestSubtitleID picks the track the download produced out of the refreshed list.
//
// Emby gives a sidecar no marker saying when it arrived, so this matches on what was
// asked for: an external track in the right language, preferring one whose forced and
// hearing-impaired flags agree with the candidate. It is allowed to fail — an empty answer
// means the client applies its own rule, which is the same thing it does on every other
// playback response.
func newestSubtitleID(subtitles []playableSubtitle, candidate subtitleCandidate) string {
wanted := normalizeSubtitleLanguage(candidate.Language)
if wanted == "" {
return ""
}
best, bestRank := "", 0
for _, subtitle := range subtitles {
if normalizeSubtitleLanguage(subtitle.Language) != wanted {
continue
}
if !strings.EqualFold(subtitle.DeliveryMethod, "External") {
continue
}
rank := 1
if subtitle.IsForced == candidate.Forced {
rank++
}
if subtitle.IsHearingImpaired == candidate.HearingImpaired {
rank++
}
if rank > bestRank {
best, bestRank = subtitle.ID, rank
}
}
return best
}
// The three listings exist only to turn an Emby item into an *arr id, so they are cached
// for minutes: a household's library does not change between one subtitle search and the
// next, and a manual search is already slow enough without three list requests in front
// of it. A cache miss is not an error — it just costs the request.
func (s *Server) bazarrMovies(ctx context.Context) ([]bazarr.Movie, error) {
var movies []bazarr.Movie
if s.cachedBazarr(ctx, bazarrMoviesCacheKey, &movies) {
return movies, nil
}
s.bazarrMu.Lock()
defer s.bazarrMu.Unlock()
if s.cachedBazarr(ctx, bazarrMoviesCacheKey, &movies) {
return movies, nil
}
movies, err := s.bazarr.Movies(ctx)
if err != nil {
return nil, err
}
s.storeBazarr(ctx, bazarrMoviesCacheKey, movies)
return movies, nil
}
func (s *Server) bazarrSeries(ctx context.Context) ([]bazarr.Series, error) {
var series []bazarr.Series
if s.cachedBazarr(ctx, bazarrSeriesCacheKey, &series) {
return series, nil
}
s.bazarrMu.Lock()
defer s.bazarrMu.Unlock()
if s.cachedBazarr(ctx, bazarrSeriesCacheKey, &series) {
return series, nil
}
series, err := s.bazarr.Series(ctx)
if err != nil {
return nil, err
}
s.storeBazarr(ctx, bazarrSeriesCacheKey, series)
return series, nil
}
func (s *Server) bazarrEpisodes(ctx context.Context, seriesID int) ([]bazarr.Episode, error) {
key := bazarrEpisodesCacheKey + strconv.Itoa(seriesID)
var episodes []bazarr.Episode
if s.cachedBazarr(ctx, key, &episodes) {
return episodes, nil
}
s.bazarrMu.Lock()
defer s.bazarrMu.Unlock()
if s.cachedBazarr(ctx, key, &episodes) {
return episodes, nil
}
episodes, err := s.bazarr.Episodes(ctx, seriesID)
if err != nil {
return nil, err
}
s.storeBazarr(ctx, key, episodes)
return episodes, nil
}
func (s *Server) cachedBazarr(ctx context.Context, key string, out any) bool {
raw, err := s.cache.Get(ctx, key)
if err != nil {
return false
}
return json.Unmarshal(raw, out) == nil
}
func (s *Server) storeBazarr(ctx context.Context, key string, value any) {
body, err := json.Marshal(value)
if err != nil {
return
}
if err := s.cache.Set(ctx, key, body, s.cfg.BazarrTTL); err != nil {
s.loggerFor(ctx).Warn("bazarr cache write failed", "key", key, "error", err)
}
}
@@ -0,0 +1,182 @@
package api
import (
"testing"
"github.com/ponzischeme89/memby/server/internal/bazarr"
)
// Matching an Emby item onto a Bazarr id is where a wrong answer is worst: it downloads a
// subtitle for one film and writes it beside another. These pin the rule.
func TestBazarrMovieMatchesOnTitleAndYear(t *testing.T) {
movies := []bazarr.Movie{
{RadarrID: 1, Title: "The Thing", Year: "1982"},
{RadarrID: 2, Title: "The Thing", Year: "2011"},
{RadarrID: 3, Title: "Arrival", Year: "2016"},
}
if got := bazarrMovieFor("The Thing", 2011, movies); got == nil || got.RadarrID != 2 {
t.Fatalf("remake matched %+v", got)
}
if got := bazarrMovieFor("the thing!", 1982, movies); got == nil || got.RadarrID != 1 {
t.Fatalf("punctuation and case should not matter, got %+v", got)
}
if got := bazarrMovieFor("Arrival", 0, movies); got == nil || got.RadarrID != 3 {
t.Fatalf("an unknown year should still match a unique title, got %+v", got)
}
if got := bazarrMovieFor("Dune", 2021, movies); got != nil {
t.Fatalf("expected no match, got %+v", got)
}
if got := bazarrMovieFor("", 2016, movies); got != nil {
t.Fatalf("a blank title must never match, got %+v", got)
}
}
// Bazarr carries the year as a string and leaves it empty often enough that treating an
// empty one as a disagreement would reject correct matches.
func TestBazarrMovieAcceptsAnEntryWithNoYear(t *testing.T) {
movies := []bazarr.Movie{{RadarrID: 4, Title: "Arrival", Year: ""}}
if got := bazarrMovieFor("Arrival", 2016, movies); got == nil || got.RadarrID != 4 {
t.Fatalf("got %+v", got)
}
}
// With two entries under one title, a year that both sides know must win over the first
// row that merely failed to disagree.
func TestBazarrMoviePrefersAnExactYearOverAYearlessFallback(t *testing.T) {
movies := []bazarr.Movie{
{RadarrID: 5, Title: "The Thing", Year: ""},
{RadarrID: 6, Title: "The Thing", Year: "1982"},
}
if got := bazarrMovieFor("The Thing", 1982, movies); got == nil || got.RadarrID != 6 {
t.Fatalf("got %+v", got)
}
}
func TestBazarrSeriesMatchesOnTitleAndYear(t *testing.T) {
series := []bazarr.Series{
{SonarrSeriesID: 1, Title: "Battlestar Galactica", Year: "1978"},
{SonarrSeriesID: 2, Title: "Battlestar Galactica", Year: "2004"},
}
if got := bazarrSeriesFor("Battlestar Galactica", 2004, series); got == nil || got.SonarrSeriesID != 2 {
t.Fatalf("got %+v", got)
}
}
func TestBazarrEpisodeMatchesOnNumbersNotTitles(t *testing.T) {
episodes := []bazarr.Episode{
{SonarrEpisodeID: 10, Season: 3, Episode: 3, Title: "Something"},
{SonarrEpisodeID: 11, Season: 3, Episode: 4, Title: "Quite different in Sonarr"},
{SonarrEpisodeID: 12, Season: 0, Episode: 4, Title: "A special"},
}
if got := bazarrEpisodeFor(episodes, 3, 4); got == nil || got.SonarrEpisodeID != 11 {
t.Fatalf("got %+v", got)
}
// Season 0 is specials, a real season — it must not be confused with "no season".
if got := bazarrEpisodeFor(episodes, 0, 4); got == nil || got.SonarrEpisodeID != 12 {
t.Fatalf("specials should match, got %+v", got)
}
if got := bazarrEpisodeFor(episodes, 3, 9); got != nil {
t.Fatalf("expected no match, got %+v", got)
}
if got := bazarrEpisodeFor(episodes, 3, 0); got != nil {
t.Fatalf("a missing episode number must never match, got %+v", got)
}
}
// The viewer's language comes first because it is the only thing they asked for; within a
// language a plain track beats a forced or hearing-impaired one, for the same reason
// selectSubtitle ranks them that way.
func TestRankSubtitleCandidatesPutsTheChosenLanguageFirst(t *testing.T) {
found := []bazarr.Subtitle{
{Language: "eng", Score: 99, Token: "en-plain"},
{Language: "ita", Score: 40, Token: "it-plain"},
{Language: "ita", Score: 98, Token: "it-forced", Forced: true},
{Language: "ita", Score: 95, Token: "it-sdh", HearingImpaired: true},
}
results := rankSubtitleCandidates(found, "it")
if len(results) != 4 {
t.Fatalf("results = %+v", results)
}
order := []string{results[0].Token, results[1].Token, results[2].Token, results[3].Token}
want := []string{"it-plain", "it-sdh", "it-forced", "en-plain"}
for i := range want {
if order[i] != want[i] {
t.Fatalf("order = %v, want %v", order, want)
}
}
}
func TestRankSubtitleCandidatesFallsBackToScoreWithNoPreference(t *testing.T) {
found := []bazarr.Subtitle{
{Language: "eng", Score: 55, Token: "low"},
{Language: "eng", Score: 92, Token: "high"},
}
for _, language := range []string{"", "auto"} {
results := rankSubtitleCandidates(found, language)
if len(results) != 2 || results[0].Token != "high" {
t.Fatalf("language %q gave %+v", language, results)
}
}
}
// A row with no token cannot be downloaded, so offering it would be a button that does
// nothing.
func TestRankSubtitleCandidatesDropsTokenlessRowsAndCaps(t *testing.T) {
found := []bazarr.Subtitle{{Language: "eng", Token: " "}}
for i := 0; i < maxSubtitleResults+5; i++ {
found = append(found, bazarr.Subtitle{Language: "eng", Score: i, Token: "t"})
}
results := rankSubtitleCandidates(found, "en")
if len(results) > maxSubtitleResults {
t.Fatalf("len = %d", len(results))
}
for _, result := range results {
if result.Token == " " {
t.Fatal("a tokenless row was offered")
}
}
}
func TestSubtitleCandidateLabelNamesTheLanguageAndKind(t *testing.T) {
for _, testCase := range []struct {
subtitle bazarr.Subtitle
want string
}{
{bazarr.Subtitle{Language: "ita", Score: 97}, "Italian · 97% match"},
{bazarr.Subtitle{Language: "eng", Forced: true, Score: 80}, "English · Forced · 80% match"},
{bazarr.Subtitle{Language: "eng", HearingImpaired: true}, "English · Hearing impaired"},
// A language Memby has no entry for must still name itself rather than go blank.
{bazarr.Subtitle{Language: "mi"}, "MI"},
{bazarr.Subtitle{}, "Unknown"},
// Bazarr has been seen to return scores above 100; a "112% match" reads as a bug.
{bazarr.Subtitle{Language: "eng", Score: 112}, "English · 100% match"},
} {
if got := subtitleCandidateLabel(testCase.subtitle); got != testCase.want {
t.Errorf("label = %q, want %q", got, testCase.want)
}
}
}
// The downloaded track has no marker saying it is new, so it is identified by what was
// asked for. Failing to identify it is allowed — the client then applies its own rule.
func TestNewestSubtitleIDPrefersTheMatchingExternalTrack(t *testing.T) {
subtitles := []playableSubtitle{
{ID: "1", Language: "eng", DeliveryMethod: "External"},
{ID: "2", Language: "ita", DeliveryMethod: "Encode"},
{ID: "3", Language: "ita", DeliveryMethod: "External", IsForced: true},
{ID: "4", Language: "ita", DeliveryMethod: "External"},
}
if got := newestSubtitleID(subtitles, subtitleCandidate{Language: "it"}); got != "4" {
t.Errorf("plain Italian = %q, want 4", got)
}
if got := newestSubtitleID(subtitles, subtitleCandidate{Language: "it", Forced: true}); got != "3" {
t.Errorf("forced Italian = %q, want 3", got)
}
if got := newestSubtitleID(subtitles, subtitleCandidate{Language: "de"}); got != "" {
t.Errorf("absent language = %q, want empty", got)
}
if got := newestSubtitleID(subtitles, subtitleCandidate{}); got != "" {
t.Errorf("no language = %q, want empty", got)
}
}
+167
View File
@@ -0,0 +1,167 @@
package api
import "strings"
// Which subtitle track a viewer gets, decided here rather than on the television.
//
// The gateway is already the thing that asks Emby what tracks exist — it calls
// PlaybackInfo and enumerates the streams — so it is also the thing that should say which
// of them to turn on. The television used to guess (forced, then default, then whatever was
// first), which meant the answer depended on which set somebody happened to be watching on,
// and turning subtitles off in the bedroom taught the living room nothing.
//
// The preference itself lives in the synced settings document (`subtitlesEnabled`,
// `subtitleLanguage`), so it follows the person rather than the box, and an operator can
// read and push it from the accounts page like any other setting.
// subtitleLanguage is one entry in the vocabulary offered for `subtitleLanguage`.
//
// Value is ISO 639-1 because that is what a media3 track reports after normalisation;
// aliases are the ISO 639-2 forms Emby actually puts in a stream's Language field, which
// for several languages is two different three-letter codes (bibliographic and
// terminological) for the same thing.
type subtitleLanguage struct {
Value string
Label string
Aliases []string
}
var subtitleLanguages = []subtitleLanguage{
{Value: "en", Label: "English", Aliases: []string{"eng"}},
{Value: "it", Label: "Italian", Aliases: []string{"ita"}},
{Value: "es", Label: "Spanish", Aliases: []string{"spa", "esp"}},
{Value: "fr", Label: "French", Aliases: []string{"fre", "fra"}},
{Value: "de", Label: "German", Aliases: []string{"ger", "deu"}},
{Value: "pt", Label: "Portuguese", Aliases: []string{"por"}},
{Value: "nl", Label: "Dutch", Aliases: []string{"dut", "nld"}},
{Value: "sv", Label: "Swedish", Aliases: []string{"swe"}},
{Value: "da", Label: "Danish", Aliases: []string{"dan"}},
{Value: "no", Label: "Norwegian", Aliases: []string{"nor", "nob", "nno"}},
{Value: "fi", Label: "Finnish", Aliases: []string{"fin"}},
{Value: "pl", Label: "Polish", Aliases: []string{"pol"}},
{Value: "cs", Label: "Czech", Aliases: []string{"cze", "ces"}},
{Value: "hu", Label: "Hungarian", Aliases: []string{"hun"}},
{Value: "ro", Label: "Romanian", Aliases: []string{"rum", "ron"}},
{Value: "el", Label: "Greek", Aliases: []string{"gre", "ell"}},
{Value: "ru", Label: "Russian", Aliases: []string{"rus"}},
{Value: "uk", Label: "Ukrainian", Aliases: []string{"ukr"}},
{Value: "tr", Label: "Turkish", Aliases: []string{"tur"}},
{Value: "ar", Label: "Arabic", Aliases: []string{"ara"}},
{Value: "he", Label: "Hebrew", Aliases: []string{"heb", "iw"}},
{Value: "hi", Label: "Hindi", Aliases: []string{"hin"}},
{Value: "ja", Label: "Japanese", Aliases: []string{"jpn"}},
{Value: "ko", Label: "Korean", Aliases: []string{"kor"}},
{Value: "zh", Label: "Chinese", Aliases: []string{"chi", "zho", "cmn", "yue"}},
{Value: "th", Label: "Thai", Aliases: []string{"tha"}},
{Value: "vi", Label: "Vietnamese", Aliases: []string{"vie"}},
}
// subtitleLanguageAuto means "no preference": keep the old flag-driven behaviour, which is
// what somebody who has never opened the subtitle menu should still get.
const subtitleLanguageAuto = "auto"
func subtitleLanguageOptions() []preferenceOption {
options := make([]preferenceOption, 0, len(subtitleLanguages)+1)
options = append(options, option(subtitleLanguageAuto, "Automatic"))
for _, language := range subtitleLanguages {
options = append(options, option(language.Value, language.Label))
}
return options
}
// normalizeSubtitleLanguage folds a stream's language onto the catalogue's vocabulary.
// Anything unrecognised comes back as the lowercased original rather than empty, so an
// exact string match on a language Memby has no entry for still works.
func normalizeSubtitleLanguage(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return ""
}
// Emby writes plain codes, but a region tag ("pt-BR") or a script suffix is possible.
if cut := strings.IndexAny(value, "-_"); cut > 0 {
value = value[:cut]
}
for _, language := range subtitleLanguages {
if language.Value == value {
return language.Value
}
for _, alias := range language.Aliases {
if alias == value {
return language.Value
}
}
}
return value
}
// selectSubtitle returns the id of the track to turn on, or "" for none.
//
// The rules, in order, and each one is there for a reason worth keeping:
//
// - Subtitles off means off. Nothing else is consulted.
// - A chosen language wins, and among tracks in it a plain full track beats a forced or
// hearing-impaired one — somebody who picked Italian wants the dialogue, not the
// signs-only track that happens to sort first.
// - A chosen language that the title does not have falls back to a *forced* track only.
// Forced subtitles carry the parts of a film that are foreign to its own audio, so they
// are wanted regardless; falling through to English instead would put a language the
// viewer did not ask for on screen.
// - With no language chosen, the old behaviour stands: forced, then default, then the
// first track. That is what an install that has never touched the setting keeps.
func selectSubtitle(subtitles []playableSubtitle, enabled bool, language string) string {
if !enabled || len(subtitles) == 0 {
return ""
}
preferred := normalizeSubtitleLanguage(language)
if preferred != "" && preferred != subtitleLanguageAuto {
if match := bestSubtitleInLanguage(subtitles, preferred); match != "" {
return match
}
return firstSubtitleWhere(subtitles, func(s playableSubtitle) bool { return s.IsForced })
}
if forced := firstSubtitleWhere(
subtitles, func(s playableSubtitle) bool { return s.IsForced },
); forced != "" {
return forced
}
if fallback := firstSubtitleWhere(
subtitles, func(s playableSubtitle) bool { return s.IsDefault },
); fallback != "" {
return fallback
}
return subtitles[0].ID
}
// bestSubtitleInLanguage ranks the tracks in one language: a plain track, then a default
// one, then hearing-impaired, then forced. Forced sorts last here precisely because it is
// not a substitute for a full track in the language somebody asked for.
func bestSubtitleInLanguage(subtitles []playableSubtitle, language string) string {
best, bestRank := "", 0
for _, subtitle := range subtitles {
if normalizeSubtitleLanguage(subtitle.Language) != language {
continue
}
rank := 4
switch {
case subtitle.IsForced:
rank = 1
case subtitle.IsHearingImpaired:
rank = 2
case subtitle.IsDefault:
rank = 5
}
if rank > bestRank {
best, bestRank = subtitle.ID, rank
}
}
return best
}
func firstSubtitleWhere(subtitles []playableSubtitle, match func(playableSubtitle) bool) string {
for _, subtitle := range subtitles {
if match(subtitle) {
return subtitle.ID
}
}
return ""
}
+96
View File
@@ -0,0 +1,96 @@
package api
import "testing"
func TestNormalizeSubtitleLanguage(t *testing.T) {
cases := map[string]string{
"ita": "it",
"ITA": "it",
" it ": "it",
"fre": "fr",
"fra": "fr",
"pt-BR": "pt",
"zho": "zh",
"": "",
// Unknown codes survive as themselves so an exact match still works for a
// language the catalogue has no entry for.
"klingon": "klingon",
}
for input, want := range cases {
if got := normalizeSubtitleLanguage(input); got != want {
t.Fatalf("normalizeSubtitleLanguage(%q) = %q, want %q", input, got, want)
}
}
}
func TestSubtitleLanguageOptionsAreLegalPreferenceValues(t *testing.T) {
definition, ok := preferenceDefinitionFor("subtitleLanguage")
if !ok {
t.Fatal("subtitleLanguage is missing from the catalogue")
}
if got := normalizePreference(definition, "it"); got != "it" {
t.Fatalf("Italian was rejected by the catalogue: %v", got)
}
if got := normalizePreference(definition, "nonsense"); got != subtitleLanguageAuto {
t.Fatalf("an illegal language became %v, want the default", got)
}
if got := normalizePreferences(map[string]any{})["subtitlesEnabled"]; got != true {
t.Fatalf("subtitlesEnabled defaulted to %v, want true", got)
}
}
func TestSelectSubtitleOffMeansOff(t *testing.T) {
subtitles := []playableSubtitle{{ID: "1", Language: "eng", IsDefault: true}}
if got := selectSubtitle(subtitles, false, "en"); got != "" {
t.Fatalf("selected %q with subtitles turned off", got)
}
}
func TestSelectSubtitlePrefersChosenLanguage(t *testing.T) {
subtitles := []playableSubtitle{
{ID: "1", Language: "eng", IsDefault: true},
{ID: "2", Language: "ita", IsForced: true},
{ID: "3", Language: "ita", IsHearingImpaired: true},
{ID: "4", Language: "ita"},
}
// The full Italian track, not the forced one that comes first and not the SDH one.
if got := selectSubtitle(subtitles, true, "it"); got != "4" {
t.Fatalf("selectSubtitle picked %q, want the full Italian track", got)
}
}
func TestSelectSubtitleMissingLanguageFallsBackToForcedOnly(t *testing.T) {
withForced := []playableSubtitle{
{ID: "1", Language: "eng", IsDefault: true},
{ID: "2", Language: "eng", IsForced: true},
}
if got := selectSubtitle(withForced, true, "it"); got != "2" {
t.Fatalf("selectSubtitle picked %q, want the forced track", got)
}
// No forced track and no Italian: nothing, rather than English the viewer never asked
// for.
withoutForced := []playableSubtitle{{ID: "1", Language: "eng", IsDefault: true}}
if got := selectSubtitle(withoutForced, true, "it"); got != "" {
t.Fatalf("selectSubtitle picked %q, want nothing", got)
}
}
func TestSelectSubtitleAutomaticKeepsFlagOrder(t *testing.T) {
subtitles := []playableSubtitle{
{ID: "1", Language: "eng"},
{ID: "2", Language: "eng", IsDefault: true},
{ID: "3", Language: "eng", IsForced: true},
}
if got := selectSubtitle(subtitles, true, subtitleLanguageAuto); got != "3" {
t.Fatalf("automatic picked %q, want the forced track", got)
}
if got := selectSubtitle(subtitles[:2], true, ""); got != "2" {
t.Fatalf("automatic picked %q, want the default track", got)
}
if got := selectSubtitle(subtitles[:1], true, subtitleLanguageAuto); got != "1" {
t.Fatalf("automatic picked %q, want the only track", got)
}
if got := selectSubtitle(nil, true, subtitleLanguageAuto); got != "" {
t.Fatalf("automatic picked %q with no tracks", got)
}
}
+16 -5
View File
@@ -11,10 +11,11 @@ import (
"github.com/ponzischeme89/memby/server/internal/appupdate"
)
// membyProtocolVersion changes only when the client/server wire contract is no longer
// ProtocolVersion changes only when the client/server wire contract is no longer
// mutually compatible. App release versions remain independent and are handled by the
// update policy.
const membyProtocolVersion = 1
// update policy. Exported so the startup line can state which contract this build
// speaks, next to the build's own version.
const ProtocolVersion = 1
// updatePolicyCache keeps the policy in memory. It is read on every home request, and a
// database round trip per home load to answer "nothing to say" would be wasteful.
@@ -79,12 +80,12 @@ func clientProtocolNumber(r *http.Request) int {
func compatibilityFor(r *http.Request) (bool, string) {
reported, err := strconv.Atoi(clientProtocol(r))
if err != nil || reported != membyProtocolVersion {
if err != nil || reported != ProtocolVersion {
if clientProtocol(r) == "" {
return false, "This Memby app is too old to verify compatibility with the server. Update the app."
}
return false, "Memby app/server mismatch: app protocol " + clientProtocol(r) +
", server protocol " + strconv.Itoa(membyProtocolVersion) + ". Update the app or server."
", server protocol " + strconv.Itoa(ProtocolVersion) + ". Update the app or server."
}
return true, ""
}
@@ -96,5 +97,15 @@ func compatibilityFor(r *http.Request) (bool, string) {
// 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))
// Only a verdict that asks a television to do something is worth a line. Every TV
// checks on every launch, and "nothing to say" logged each time would bury the
// launch where an update was actually offered — or forced.
if decision.Status != "" && decision.Status != appupdate.StatusNone {
s.loggerFor(r.Context()).Info("update offered",
"status", decision.Status,
"from", clientLogValue(clientVersion(r)),
"to", decision.Version,
)
}
writeJSON(w, http.StatusOK, decision)
}
+317
View File
@@ -0,0 +1,317 @@
// Package bazarr provides the slice of Bazarr Memby uses to fetch a subtitle a title does
// not have yet.
//
// Bazarr is the subtitle companion to Sonarr and Radarr, which the gateway already talks
// to, and it is the reason this feature needs no storage of its own: Bazarr writes the
// subtitle file beside the media file, so Emby finds it on the next refresh and the track
// arrives through the same PlaybackInfo path as every other subtitle. Memby never holds a
// subtitle, never serves one, and never learns a provider's credentials.
//
// Two things about the API are worth stating because they are not obvious and both would
// otherwise be discovered as a bug. Bazarr keys everything on the *arr's id, not its own:
// a movie is a `radarrid` and an episode is an `episodeid` from Sonarr, which is why
// resolving an Emby item to one of them is a real step rather than a lookup. And the
// manual-search result rows are opaque — the `subtitle` field is a provider-specific token
// that has to be handed back verbatim on the download call, so nothing here parses or
// reconstructs it.
package bazarr
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type Client struct {
baseURL string
apiKey string
http *http.Client
}
// Movie is one row of Bazarr's movie list. Only the fields Memby matches an Emby item
// against, plus the id it needs afterwards, are decoded.
type Movie struct {
RadarrID int `json:"radarrId"`
Title string `json:"title"`
Year string `json:"year"`
Path string `json:"path"`
}
type Series struct {
SonarrSeriesID int `json:"sonarrSeriesId"`
Title string `json:"title"`
Year string `json:"year"`
}
type Episode struct {
SonarrEpisodeID int `json:"sonarrEpisodeId"`
SonarrSeriesID int `json:"sonarrSeriesId"`
Season int `json:"season"`
Episode int `json:"episode"`
Title string `json:"title"`
}
// Subtitle is one candidate from a manual search.
//
// Score is Bazarr's own 0100 confidence that the subtitle matches this exact release, and
// it is the only thing on the row a viewer can sensibly choose by — provider names and
// release strings mean nothing on a television. Token is the opaque value the download
// call needs; it is never interpreted here.
type Subtitle struct {
Language string `json:"language"`
Forced bool `json:"forced"`
HearingImpaired bool `json:"hearing_impaired"`
Provider string `json:"provider"`
Token string `json:"subtitle"`
Score int `json:"score"`
ReleaseInfo []string `json:"release_info"`
Uploader string `json:"uploader"`
OriginalFormat bool `json:"original_format"`
}
// hearingImpaired and forced arrive from Bazarr as either a bool or one of its
// "True"/"False"/"" strings depending on the endpoint and version. Decoding them as
// json.RawMessage and folding here is what stops a version bump from silently turning
// every result into a non-forced one.
func (s *Subtitle) UnmarshalJSON(data []byte) error {
type raw struct {
Language json.RawMessage `json:"language"`
Forced json.RawMessage `json:"forced"`
HearingImpaired json.RawMessage `json:"hearing_impaired"`
Provider string `json:"provider"`
Token string `json:"subtitle"`
Score int `json:"score"`
ReleaseInfo []string `json:"release_info"`
Uploader string `json:"uploader"`
OriginalFormat json.RawMessage `json:"original_format"`
}
var decoded raw
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
s.Language = flexibleLanguage(decoded.Language)
s.Forced = flexibleBool(decoded.Forced)
s.HearingImpaired = flexibleBool(decoded.HearingImpaired)
s.Provider = decoded.Provider
s.Token = decoded.Token
s.Score = decoded.Score
s.ReleaseInfo = decoded.ReleaseInfo
s.Uploader = decoded.Uploader
s.OriginalFormat = flexibleBool(decoded.OriginalFormat)
return nil
}
// flexibleBool accepts true, "True", "true" and "1" as true, and treats anything else —
// including an absent field — as false.
func flexibleBool(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
var asBool bool
if json.Unmarshal(raw, &asBool) == nil {
return asBool
}
var asString string
if json.Unmarshal(raw, &asString) != nil {
return false
}
switch strings.ToLower(strings.TrimSpace(asString)) {
case "true", "1", "yes":
return true
default:
return false
}
}
// flexibleLanguage takes the code out of either shape Bazarr uses for a language: a plain
// string, or an object carrying `code2`/`code3` and a name.
func flexibleLanguage(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var asString string
if json.Unmarshal(raw, &asString) == nil {
return strings.TrimSpace(asString)
}
var asObject struct {
Code2 string `json:"code2"`
Code3 string `json:"code3"`
Name string `json:"name"`
}
if json.Unmarshal(raw, &asObject) != nil {
return ""
}
for _, candidate := range []string{asObject.Code2, asObject.Code3, asObject.Name} {
if value := strings.TrimSpace(candidate); value != "" {
return value
}
}
return ""
}
type APIError struct {
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("bazarr: 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: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
// Movies returns every movie Bazarr manages. Bazarr pages this endpoint, and asking for
// everything in one call is deliberate: the result is cached by the caller for minutes at
// a time, and walking pages would multiply that one cold request by the size of a library.
func (c *Client) Movies(ctx context.Context) ([]Movie, error) {
var payload struct {
Data []Movie `json:"data"`
}
if err := c.get(ctx, "/api/movies", url.Values{"length": {"-1"}}, &payload); err != nil {
return nil, err
}
return payload.Data, nil
}
func (c *Client) Series(ctx context.Context) ([]Series, error) {
var payload struct {
Data []Series `json:"data"`
}
if err := c.get(ctx, "/api/series", url.Values{"length": {"-1"}}, &payload); err != nil {
return nil, err
}
return payload.Data, nil
}
func (c *Client) Episodes(ctx context.Context, seriesID int) ([]Episode, error) {
var payload struct {
Data []Episode `json:"data"`
}
params := url.Values{"seriesid[]": {strconv.Itoa(seriesID)}}
if err := c.get(ctx, "/api/episodes", params, &payload); err != nil {
return nil, err
}
return payload.Data, nil
}
// SearchMovie runs Bazarr's manual search for a film. It is a live provider query, so it
// is slow — seconds, not milliseconds — which is why the television is told to expect a
// wait rather than being left with a spinner that looks stuck.
func (c *Client) SearchMovie(ctx context.Context, radarrID int) ([]Subtitle, error) {
return c.search(ctx, "/api/providers/movies", url.Values{"radarrid": {strconv.Itoa(radarrID)}})
}
func (c *Client) SearchEpisode(ctx context.Context, episodeID int) ([]Subtitle, error) {
return c.search(ctx, "/api/providers/episodes", url.Values{"episodeid": {strconv.Itoa(episodeID)}})
}
func (c *Client) search(ctx context.Context, path string, params url.Values) ([]Subtitle, error) {
var subtitles []Subtitle
if err := c.get(ctx, path, params, &subtitles); err != nil {
return nil, err
}
return subtitles, nil
}
// DownloadMovie tells Bazarr to fetch one of the search results and write it beside the
// film. The token is handed back exactly as it arrived.
func (c *Client) DownloadMovie(ctx context.Context, radarrID int, subtitle Subtitle) error {
return c.download(ctx, "/api/providers/movies", url.Values{
"radarrid": {strconv.Itoa(radarrID)},
}, subtitle)
}
func (c *Client) DownloadEpisode(ctx context.Context, seriesID, episodeID int, subtitle Subtitle) error {
return c.download(ctx, "/api/providers/episodes", url.Values{
"seriesid": {strconv.Itoa(seriesID)},
"episodeid": {strconv.Itoa(episodeID)},
}, subtitle)
}
func (c *Client) download(ctx context.Context, path string, target url.Values, subtitle Subtitle) error {
form := url.Values{}
for key, values := range target {
form[key] = values
}
form.Set("language", subtitle.Language)
form.Set("hi", strconv.FormatBool(subtitle.HearingImpaired))
form.Set("forced", strconv.FormatBool(subtitle.Forced))
form.Set("original_format", strconv.FormatBool(subtitle.OriginalFormat))
form.Set("provider", subtitle.Provider)
form.Set("subtitle", subtitle.Token)
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, c.baseURL+path, strings.NewReader(form.Encode()),
)
if err != nil {
return err
}
req.Header.Set("X-API-KEY", c.apiKey)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
// Bazarr answers a successful download with an empty body, so there is nothing to
// decode — only a status to check.
return c.do(req, nil)
}
// Ping is the reachability check the admin console and startup use. Bazarr's status
// endpoint needs no arguments and returns quickly even when providers are slow.
func (c *Client) Ping(ctx context.Context) error {
var payload struct {
Data map[string]any `json:"data"`
}
return c.get(ctx, "/api/system/status", nil, &payload)
}
func (c *Client) get(ctx context.Context, path string, params url.Values, out any) 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 err
}
req.Header.Set("X-API-KEY", c.apiKey)
req.Header.Set("Accept", "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("bazarr: %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 out == nil {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("bazarr: decode response: %w", err)
}
return nil
}
+184
View File
@@ -0,0 +1,184 @@
package bazarr
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
)
func TestSearchMovieSendsRadarrIDAndKeyInHeader(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/providers/movies" {
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 got := r.URL.Query().Get("radarrid"); got != "42" {
t.Errorf("radarrid = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"language":"en","forced":false,"hearing_impaired":"False","provider":"opensubtitles",
"subtitle":"opaque-token","score":97,"release_info":["Arrival.2016.1080p"],"uploader":"someone"}
]`))
}))
defer upstream.Close()
subtitles, err := New(upstream.URL, "secret", time.Second).SearchMovie(context.Background(), 42)
if err != nil {
t.Fatal(err)
}
if len(subtitles) != 1 {
t.Fatalf("subtitles = %+v", subtitles)
}
got := subtitles[0]
if got.Language != "en" || got.Score != 97 || got.Token != "opaque-token" {
t.Fatalf("unexpected subtitle: %+v", got)
}
if got.HearingImpaired {
t.Error(`hearing_impaired "False" decoded as true`)
}
}
// Bazarr has shipped both shapes for these fields across versions. Decoding either is what
// stops an upgrade from silently turning every result into a plain, non-forced track.
func TestSubtitleDecodesBoolAndStringFlagsAndBothLanguageShapes(t *testing.T) {
for _, payload := range []struct {
name string
json string
}{
{"strings", `{"language":"it","forced":"True","hearing_impaired":"true","subtitle":"t"}`},
{"bools", `{"language":"it","forced":true,"hearing_impaired":true,"subtitle":"t"}`},
{"language object", `{"language":{"code2":"it","name":"Italian"},"forced":true,"hearing_impaired":1,"subtitle":"t"}`},
} {
t.Run(payload.name, func(t *testing.T) {
var subtitle Subtitle
if err := json.Unmarshal([]byte(payload.json), &subtitle); err != nil {
t.Fatal(err)
}
if subtitle.Language != "it" {
t.Errorf("language = %q", subtitle.Language)
}
if !subtitle.Forced {
t.Error("forced = false")
}
// `1` is a number, not one of the accepted strings, so the last case is
// deliberately allowed to be false — the point is that it decodes at all.
if payload.name != "language object" && !subtitle.HearingImpaired {
t.Error("hearing_impaired = false")
}
})
}
}
// The result row is opaque and has to be handed back exactly as it arrived; reconstructing
// it from the parsed fields is the mistake this pins against.
func TestDownloadMoviePostsTheTokenVerbatim(t *testing.T) {
var form url.Values
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/providers/movies" {
t.Errorf("%s %s", r.Method, r.URL.Path)
}
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
form = r.PostForm
w.WriteHeader(http.StatusNoContent)
}))
defer upstream.Close()
err := New(upstream.URL, "secret", time.Second).DownloadMovie(context.Background(), 42, Subtitle{
Language: "en", Provider: "opensubtitles", Token: "opaque {token} with spaces", HearingImpaired: true,
})
if err != nil {
t.Fatal(err)
}
if got := form.Get("subtitle"); got != "opaque {token} with spaces" {
t.Errorf("subtitle = %q", got)
}
if got := form.Get("radarrid"); got != "42" {
t.Errorf("radarrid = %q", got)
}
if got := form.Get("hi"); got != "true" {
t.Errorf("hi = %q", got)
}
if got := form.Get("forced"); got != "false" {
t.Errorf("forced = %q", got)
}
}
func TestDownloadEpisodeCarriesBothIDs(t *testing.T) {
var form url.Values
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
form = r.PostForm
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(``))
}))
defer upstream.Close()
err := New(upstream.URL, "secret", time.Second).
DownloadEpisode(context.Background(), 8, 91, Subtitle{Language: "en", Token: "t"})
if err != nil {
t.Fatal(err)
}
if form.Get("seriesid") != "8" || form.Get("episodeid") != "91" {
t.Errorf("form = %v", form)
}
}
func TestListingsUnwrapTheDataEnvelopeAndAskForEverything(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("length"); got != "-1" && r.URL.Path != "/api/episodes" {
t.Errorf("length = %q for %s", got, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/movies":
_, _ = w.Write([]byte(`{"data":[{"radarrId":42,"title":"Arrival","year":"2016"}]}`))
case "/api/series":
_, _ = w.Write([]byte(`{"data":[{"sonarrSeriesId":8,"title":"Severance","year":"2022"}]}`))
case "/api/episodes":
if got := r.URL.Query().Get("seriesid[]"); got != "8" {
t.Errorf("seriesid[] = %q", got)
}
_, _ = w.Write([]byte(`{"data":[{"sonarrEpisodeId":91,"sonarrSeriesId":8,"season":3,"episode":4}]}`))
}
}))
defer upstream.Close()
client := New(upstream.URL, "secret", time.Second)
movies, err := client.Movies(context.Background())
if err != nil || len(movies) != 1 || movies[0].RadarrID != 42 {
t.Fatalf("movies = %+v, err = %v", movies, err)
}
series, err := client.Series(context.Background())
if err != nil || len(series) != 1 || series[0].SonarrSeriesID != 8 {
t.Fatalf("series = %+v, err = %v", series, err)
}
episodes, err := client.Episodes(context.Background(), 8)
if err != nil || len(episodes) != 1 || episodes[0].SonarrEpisodeID != 91 {
t.Fatalf("episodes = %+v, err = %v", episodes, err)
}
}
func TestUpstreamFailureCarriesTheStatus(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "no providers enabled", http.StatusBadRequest)
}))
defer upstream.Close()
_, err := New(upstream.URL, "secret", time.Second).SearchMovie(context.Background(), 1)
apiErr, ok := err.(*APIError)
if !ok {
t.Fatalf("err = %T %v", err, err)
}
if apiErr.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d", apiErr.StatusCode)
}
}
+1
View File
@@ -0,0 +1 @@
0.1.20
+32
View File
@@ -0,0 +1,32 @@
// Package buildinfo names the running gateway build.
//
// Every log line, the health endpoint and the admin page carry it, because the first
// question about any reported behaviour is "which server is that?" — and the gateway is
// deployed from a working tree rather than a tagged artefact, so nothing else can answer.
package buildinfo
import (
_ "embed"
"os"
"strings"
)
// version is the checked-in release name. It travels with the source that is deployed,
// so a container built from a tree always reports that tree's version — no build
// argument to remember and no way for the number to be right on one host and wrong on
// another.
//
//go:embed VERSION
var version string
// Version returns the running build's version. MEMBY_SERVER_VERSION overrides it, which
// is how a one-off build ("0.1.0+hotfix") can identify itself without editing the file.
func Version() string {
if override := strings.TrimSpace(os.Getenv("MEMBY_SERVER_VERSION")); override != "" {
return override
}
if trimmed := strings.TrimSpace(version); trimmed != "" {
return trimmed
}
return "unknown"
}
+25 -2
View File
@@ -25,7 +25,9 @@ type Config struct {
RedisURL string
// ClientName is reported to Emby in the X-Emby-Authorization header, so sessions
// show up as this in Emby's dashboard.
// show up as this in Emby's dashboard. It is deliberately not the app's own name:
// the header travels to whatever Emby does with its logs, and the product name has
// no business being the thing that identifies a client to a third party.
ClientName string
HomeTTL time.Duration
@@ -103,6 +105,20 @@ type Config struct {
// news, and each TV dedupes by alert id. Zero turns the banners off.
RadarrAlertWindow time.Duration
// Bazarr is optional and is what lets a viewer fetch a subtitle a title does not have.
// It writes the file beside the media, so Emby serves the result and the gateway needs
// no storage of its own. Unset means the player simply never offers the option.
BazarrURL string
BazarrAPIKey string
// BazarrTTL is how long the movie/series/episode listings are cached. They exist only
// to turn an Emby item into the *arr id Bazarr keys on, and a household's library does
// not change between one subtitle search and the next.
BazarrTTL time.Duration
// BazarrTimeout bounds a manual search, which queries live subtitle providers and is
// legitimately slow. It is separate from BazarrTTL because a short HTTP timeout here
// shows up as "no subtitles found" rather than as an error.
BazarrTimeout 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.
@@ -127,7 +143,7 @@ func Load() (Config, error) {
EmbyPublicURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_PUBLIC_URL"), "/"),
DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"),
RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"),
ClientName: env("MEMBY_CLIENT_NAME", "Memby"),
ClientName: env("MEMBY_CLIENT_NAME", "MbyATV"),
HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second),
ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute),
SearchTTL: duration("MEMBY_SEARCH_TTL", 5*time.Minute),
@@ -163,6 +179,10 @@ func Load() (Config, error) {
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),
BazarrURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_BAZARR_URL")), "/"),
BazarrAPIKey: strings.TrimSpace(os.Getenv("MEMBY_BAZARR_API_KEY")),
BazarrTTL: duration("MEMBY_BAZARR_TTL", 5*time.Minute),
BazarrTimeout: duration("MEMBY_BAZARR_TIMEOUT", 45*time.Second),
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")),
@@ -191,6 +211,9 @@ func Load() (Config, error) {
if (c.RadarrURL == "") != (c.RadarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_RADARR_URL and MEMBY_RADARR_API_KEY must be set together")
}
if (c.BazarrURL == "") != (c.BazarrAPIKey == "") {
return c, fmt.Errorf("MEMBY_BAZARR_URL and MEMBY_BAZARR_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")
}

Some files were not shown because too many files have changed in this diff Show More