Files
memby/server/README.md
T
ponzischeme89andClaude Opus 5 62f6345a40 Server-controlled app updates, optional or forced
The gateway decides whether a TV may keep running its current build.
Clients send X-Memby-Version on every request and ask GET /v1/update on
each launch; the verdict is none, optional or mandatory.

- internal/appupdate holds the decision as pure, tested logic: below
  minimumVersion is mandatory, below latestVersion is optional.
- Admin page gains an App updates section — latest version, APK URL,
  notes, and a "Require this update" toggle that sets the forced floor.
- Client shows a dismissable prompt for optional, and a full-screen
  panel that swallows Back for mandatory. Instructions say what the
  system installer will ask before it asks.

Two safeguards: a client that cannot report its version is never forced,
and the client ignores a verdict with no download URL, so a
half-configured policy cannot produce an unblockable screen with a dead
button. An unreachable gateway shows nothing.

The verdict is deliberately not part of /v1/home: that payload is cached
per user, while this answer depends on the requesting client's version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:34:04 +12:00

279 lines
14 KiB
Markdown

# Memby gateway
A small Go service that sits between the Memby Android TV client and Emby. It owns
authentication, caching, search and the shaping of TV screens, so the client can stay a
thin renderer.
Video never passes through here. `/v1/items/{id}/playback` returns a direct-play URL
pointing at Emby itself; only metadata and artwork traverse the gateway.
## Run it
```bash
cp .env.example .env # from the repo root
$EDITOR .env # set MEMBY_EMBY_URL and POSTGRES_PASSWORD
docker compose up -d --build
curl localhost:8080/readyz
```
Then build the TV app against it:
```powershell
.\gradlew.bat assembleDebug -Pmemby.gatewayUrl=http://<host>:8080
```
Leaving `memby.gatewayUrl` blank keeps the app on its original direct-to-Emby path, so a
gateway outage is one rebuild away from being routed around.
## Local development
```bash
go build ./...
go test ./...
go run ./cmd/memby-server # needs Postgres + Redis reachable
```
On Windows, `go` may need `-buildvcs=false` when the working tree has no usable `.git`.
## API
All `/v1` routes need `Authorization: Bearer <token>` from `/v1/auth/login`. Image URLs
accept `?t=<token>` instead, because the client's image loader fetches plain URLs with no
headers attached.
| Method | Path | Purpose |
| --- | --- | --- |
| POST | `/v1/auth/login` | Emby credentials in, gateway token out |
| POST | `/v1/auth/logout` | Retire this device's token |
| GET | `/v1/auth/session` | Confirm a stored token is still valid |
| GET | `/v1/home?limit=` | **Every launcher row in one response** |
| GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild |
| GET | `/v1/update` | Whether this client (per `X-Memby-Version`) must update |
| GET | `/v1/screensaver?limit=` | Backdrop pool, cached and shuffled per request |
| GET | `/v1/search?q=&limit=` | Library search |
| GET | `/v1/items/{id}` | Full metadata for one item |
| GET | `/v1/items/{id}/playback` | Resolves series → episode, returns a direct-play URL |
| GET | `/v1/items/{id}/trailer` | First local trailer, or 404 |
| POST | `/v1/items/{id}/favorite` | `{"value":true}` |
| POST | `/v1/items/{id}/played` | `{"value":true}` |
| POST | `/v1/playback/{started\|progress\|stopped}` | Progress reporting |
| POST | `/v1/analytics/rows` | Batched row engagement from a TV |
| GET | `/v1/images/{itemId}/{backdrop\|primary\|logo\|thumb}` | Artwork proxy |
| GET | `/healthz`, `/readyz` | Liveness, readiness |
### Server-driven rows
`/v1/home` returns a `rows` array — order, titles and kinds all decided here — plus the
four fixed rows repeated flat for the client's offline cache:
```json
{
"rows": [
{"id": "continue", "title": "Continue Watching", "kind": "continue", "items": [...]},
{"id": "next-up", "title": "Next Up", "kind": "nextup", "items": [...]},
{"id": "favorites", "title": "Favourites", "kind": "favorites", "items": [...]},
{"id": "latest-movies", "title": "Recently Added Movies", "kind": "latest", "items": [...]},
{"id": "similar:sev", "title": "Because you watched Severance", "kind": "similar", "items": [...]},
{"id": "recommended", "title": "Recommended from your watching history", "kind": "recommended", "items": [...]}
],
"continueWatching": [...], "nextUp": [...], "favorites": [...], "latestMovies": [...],
"partial": false
}
```
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.
The user's own section toggles still hide the four fixed rows, but never rows the server
invented — nobody opted out of a row that did not exist when they last opened Settings.
### Recommendations
`internal/recommend` builds rows from viewing history. Two kinds:
- **"Because you watched X"** — Emby's own `/Items/{id}/Similar` for the most recent
distinct titles, filtered down to what the user has not seen. Emby's similarity ranking
is better than anything worth reimplementing here; this only removes the already-watched.
- **"Recommended from your watching history"** — genre and studio affinity. History is
weighted by recency (0.94 per position, so the 12th item counts about half the most
recent), favourites add a smaller fixed weight, and candidates are unplayed titles in the
top three genres scored by affinity + a mild community-rating nudge. Titles tagged with
many genres get a `sqrt(n)` penalty so genre-stuffing cannot buy a top slot.
Rows shorter than four items are dropped, and a user with no history gets no rows at all
rather than a strip of noise.
**The home screen never waits on the engine.** Rows live in their own `r:<userId>:rows`
cache key with a long TTL (2h). A cache miss serves home immediately without them and
triggers a background rebuild — deduplicated per user, so four TVs waking together do the
work once. Because the key sits outside the `u:` namespace, a favourite toggle does not
throw the recommendations away; only a finished playback does, since that is the one event
that genuinely changes viewing history.
The scoring is pure and unit-tested (`profile_test.go`), and the row assembly runs against
a fake Emby (`engine_test.go`), so neither needs a server to verify.
Item payloads are Emby's own JSON, forwarded verbatim. That is deliberate: the Android
client already models this shape, so there is no second schema to keep in sync.
`app/src/test/.../GatewayPayloadTest.kt` and `internal/api/api_test.go` pin the envelope
around it from both sides.
## Admin interface
`http://<host>:8080/admin/` — a single self-contained page for library imports, the
maintenance switch and row engagement. Set `MEMBY_ADMIN_TOKEN` to enable it; unset, every
`/admin` route 404s so it cannot be left exposed by accident. Paste the token into the
field at the top of the page; it is kept in the browser's local storage and sent as a
bearer header. Put the whole path behind your reverse proxy's own auth as well if the
gateway is reachable from outside the LAN.
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/admin/` | The page |
| GET | `/admin/api/status` | Library counts, sync history, maintenance state |
| POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` |
| POST | `/admin/api/maintenance` | `{"enabled":true,"message":"…"}` |
| POST | `/admin/api/update-policy` | `{"enabled":true,"latestVersion":"0.1.54","downloadUrl":"…","required":false}` |
| GET | `/admin/api/analytics?days=7` | Row engagement |
## Library import
`internal/library` copies Emby's catalogue into Postgres so the gateway answers from its
own data instead of asking Emby per request.
- **Full** — pages through everything (500 items per request), then deletes any row it did
not touch, which is how removals propagate. Run once to seed; re-run after reorganising
the library.
- **Incremental** — asks Emby only for items changed since the last successful run
(`MinDateLastSaved`, with a minute of overlap so nothing falls between runs). This is
the hourly job: new episodes appear within the hour, and the weekly film drop rides
along with no extra configuration.
An incremental run with no previous success upgrades itself to a full one, so a fresh
deployment self-seeds on its first tick. Only one import runs at a time; the scheduler
skips its tick if one is still going, and interrupted runs are marked failed at boot
rather than sitting on "running" forever.
**Credentials.** Imports use `MEMBY_SYNC_USER_ID` + `MEMBY_SYNC_API_KEY` when set, and
otherwise borrow the most recently active TV session. The fallback means a new deployment
imports as soon as somebody signs in, but it stops working if that user is deleted — set a
service account for anything long-lived.
**What is *not* imported:** every query runs with `EnableUserData=false`. Watched flags,
favourites and resume positions are per-user and cannot be shared across a household, so
they still come from Emby live. The imported copy powers search and the recommendation
candidate pool.
## Maintenance mode
Takes Memby down independently of Emby: all `/v1` routes answer `503` with
`{"maintenance": true, "message": "…"}`, and the TV shows the operator's message instead of
a network error. `/healthz`, `/readyz` and `/admin` stay up — they are what you need while
the app is deliberately off.
The switch lives in Postgres, not memory, so a restart cannot quietly bring the app back
up mid-repair. Each instance caches it and re-reads every 30 seconds, so toggling it
directly in the database works too.
## App update policy
The gateway decides whether a TV may keep running its current build. Clients send
`X-Memby-Version` on every request and ask `GET /v1/update` on each launch; the verdict is
`none`, `optional` or `mandatory`.
Set it on the admin page: **latest version**, **APK URL** (normally the same file the
landing page serves), release notes, and a **Require this update** toggle.
- *Optional* — a dismissable prompt. Dismissal lasts for that session only.
- *Required* — a full-screen panel over the home screen with no way past it. Back is
swallowed and there is one button. Use it when a build is genuinely unusable, not for
ordinary releases.
"Required" sets `minimumVersion = latestVersion`; anything below that floor is forced.
`minimumVersion` can also be set directly for a staged rollout where the forced floor is
older than the newest build.
Two deliberate safeguards, both tested in `internal/appupdate`:
- A client that cannot report a version is **never** forced. It would otherwise be stuck
behind a prompt it may have no way to satisfy.
- The client only shows a verdict carrying a download URL, so a misconfigured policy
cannot produce an unblockable screen with a dead button. An unreachable gateway shows
nothing at all.
The verdict is deliberately *not* part of `/v1/home`: that payload is cached per user,
while this answer depends on the requesting client's version, so sharing a cache entry
would hand one TV's answer to another.
## Row analytics
The TV reports three signals per row — `impression` (drawn), `focus` (the remote landed
there, with dwell), `select` (something was opened) — batched and uploaded every 20
seconds to `POST /v1/analytics/rows`. Dwell below 400 ms is dropped client-side as D-pad
travel rather than attention, and the server clamps anything over 30 minutes.
Read it at `/admin/`, sorted by dwell. Dwell is the number worth watching: impressions
only say a row was on screen, while dwell says someone stopped there. It is the fastest
way to tell whether "Recommended from your watching history" is earning its slot.
Raw events are pruned after `MEMBY_ANALYTICS_RETENTION` (90 days) and aggregates are
computed at read time, so nothing survives the prune. This is tuning telemetry, not a
record of what anyone watched.
## Caching
Redis holds everything user-scoped under `u:<embyUserId>:*`, plus session lookups under
`sess:<tokenHash>` and recommendation rows under `r:<embyUserId>:rows`. Any mutation —
favourite, watched, playback stopped — drops the `u:` keys, so the next home request
re-reads Emby rather than serving a row it just contradicted. Partial home payloads are
served but never cached. The `r:` namespace is deliberately excluded from that wipe (see
Recommendations above).
Postgres holds only sessions. It is the durable half: losing Redis costs a cold cache,
losing Postgres signs everyone out.
## Configuration
| Variable | Default | Notes |
| --- | --- | --- |
| `MEMBY_EMBY_URL` | *required* | How the gateway reaches Emby |
| `MEMBY_EMBY_PUBLIC_URL` | = `MEMBY_EMBY_URL` | What TVs stream from |
| `MEMBY_DATABASE_URL` | *required* | Postgres DSN |
| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | |
| `MEMBY_LISTEN_ADDR` | `:8080` | |
| `MEMBY_CLIENT_NAME` | `Memby` | Shown in Emby's device list |
| `MEMBY_HOME_TTL` | `60s` | Also `MEMBY_ITEM_TTL`, `MEMBY_SEARCH_TTL`, `MEMBY_SCREENSAVER_TTL` |
| `MEMBY_RECOMMEND_TTL` | `2h` | How long computed recommendation rows stay warm |
| `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild |
| `MEMBY_ADMIN_TOKEN` | *empty* | Enables `/admin`. Empty = admin disabled |
| `MEMBY_SYNC_INTERVAL` | `1h` | Incremental import cadence; `0` disables |
| `MEMBY_SYNC_TIMEOUT` | `30m` | Bounds one import |
| `MEMBY_SYNC_ON_START` | `false` | Import at boot |
| `MEMBY_SYNC_USER_ID` / `MEMBY_SYNC_API_KEY` | *empty* | Emby service account for imports |
| `MEMBY_ANALYTICS_RETENTION` | `2160h` (90d) | Raw row events are pruned past this |
| `MEMBY_SESSION_CACHE_TTL` | `5m` | How long a token lookup stays in Redis |
| `MEMBY_SESSION_IDLE_EXPIRY` | `2160h` (90d) | Unused tokens are swept every 6h |
| `MEMBY_UPSTREAM_TIMEOUT` | `20s` | |
## Security notes
- The `sessions` table stores **live Emby access tokens** in plaintext. Gateway tokens are
stored only as SHA-256 hashes, so a database dump does not yield working gateway
credentials — but it does yield working *Emby* ones. Treat the Postgres volume as a
secret store, and encrypting `emby_token` at rest is the obvious next hardening step.
- Image URLs carry the gateway token in a query string, so it will appear in any access
log in front of this service. That token is revocable and grants nothing outside Memby,
which is why the artwork proxy exists at all.
- Nothing here terminates TLS. Put it behind your existing reverse proxy before exposing
it beyond the LAN.
## Not built yet
- **Live change feed.** Imports are polled hourly rather than driven by Emby's WebSocket,
so a brand-new episode can be up to an hour late. Good enough for a household; the
WebSocket would make it instant.
- **Cache warming.** Rows go cold after `MEMBY_HOME_TTL`; the first TV to ask pays for the
refresh. A background refresher per active session would hide that.
- **Rate limiting** on `/v1/auth/login`.
- **Per-user analytics breakdown.** Events carry a user id, but the admin page only shows
totals per row.