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:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
+14
-2
@@ -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
@@ -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 (0–23) for the daily prepared rebuild |
|
||||
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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>
|
||||
@@ -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 person’s 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 person’s 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'));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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) })));
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{}},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 0–100 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
0.1.20
|
||||
@@ -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,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
Reference in New Issue
Block a user