Files
memby/server/README.md
T
2026-07-27 21:06:51 +12:00

19 KiB

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

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:

.\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

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.

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.

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.

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 + device identity in, gateway token out
GET /v1/auth/policy Public client allowance used by the sign-in screen
POST /v1/auth/logout Retire this device's token
GET /v1/auth/session Confirm a stored token is still valid
GET /v1/status Lightweight live maintenance state; remains available during maintenance
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:

{
  "rows": [
    {"id": "continue",      "title": "Continue Watching", "kind": "continue",    "items": [...]},
    {"id": "next-up",       "title": "Next Up",           "kind": "nextup",      "items": [...]},
    {"id": "sonarr-airing-today", "title": "Shows airing today", "kind": "schedule", "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": [...]},
    {"id": "curated:apple-tv", "title": "Apple TV+ Shows", "kind": "shows", "items": [...]},
    {"id": "curated:drama-shows", "title": "Drama TV Shows", "kind": "shows", "items": [...]},
    {"id": "curated:comedy-shows", "title": "Comedy TV Shows", "kind": "shows", "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.
  • Curated TV shelves — Apple TV+, Drama and Comedy membership is filtered from the imported Postgres catalogue. Within each shelf, unseen shows are ranked using the same personal genre/studio profile; the shelves themselves are ordered by affinity too. A new profile falls back to community rating until it has viewing history.

Rows shorter than four items are dropped, and a user with no history gets no rows at all rather than a strip of noise, except curated shelves: these remain useful with their quality-ranked fallback.

The home screen never waits on the engine. Rows live in their own r:<userId>:rows:v2 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.

Curated shelves require at least one completed library import because their genre/studio filter runs against Postgres. Emby's exact studio naming is preserved during import; Apple matching accepts Apple TV+, Apple TV Plus and Apple Studios.

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.

Sonarr schedule

When MEMBY_SONARR_URL and MEMBY_SONARR_API_KEY are set, the gateway reads Sonarr's v3 calendar and inserts Shows airing today directly after Next Up. Cards show the local air time, season/episode number, episode title and one of: upcoming, downloading, awaiting download, unmonitored, or the exact time Sonarr added the episode file.

This row is informational. An episode listed before it is downloaded is not an Emby item, so the TV deliberately does not offer Play, Favourite or Watched actions on it. Sonarr poster and fanart requests are proxied through the gateway; its API key is never sent to the TV. The shared Redis entry is refreshed every five minutes by default, not once per user.

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. The exact /v1/status control route stays up too: open clients poll it every ten seconds, stop active playback, and move immediately to the maintenance screen. /healthz, /readyz and /admin also 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 and hourly while left open; the verdict is none, optional or mandatory.

Automated tagged releases

.gitea/workflows/release.yml turns a pushed semantic-version tag into an update:

  1. Gitea Actions derives the Android version from a tag such as v0.1.54.
  2. It runs the tests and builds the APK with the established release signing key.
  3. It uploads the signed APK to POST /admin/api/release.
  4. The gateway stores the APK in its memby-releases volume and makes it the latest optional update. Older TVs prompt on their next check.

The repository needs an Actions runner with the ubuntu-latest label and these Actions secrets:

Secret Purpose
ANDROID_KEYSTORE_BASE64 Existing release keystore, base64 encoded as one line
ANDROID_KEYSTORE_PASSWORD Keystore password
ANDROID_KEY_ALIAS Signing alias
ANDROID_KEY_PASSWORD Key password
MEMBY_RELEASE_PUBLISH_TOKEN Same dedicated value configured on the gateway

Enable repository Actions and give the job permission to read contents. Then releasing from any clone is:

git tag -a v0.1.54 -m "Personalised favourites"
git push origin main v0.1.54

Tags are the release boundary; an ordinary branch push never publishes an APK. Android will only accept updates signed by the same keystore as the installed app.

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. A session is unique per Emby user and stable device ID; signing the same TV in again rotates its token, while a new TV is rejected once MEMBY_MAX_CLIENTS_PER_USER is reached.

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_LOG_LEVEL INFO Use DEBUG for successful probe, status-poll and artwork requests
MEMBY_TIMEZONE Pacific/Auckland Local day and time labels for schedule rows
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_PUBLIC_URL empty Public gateway origin used for APK download links
MEMBY_RELEASE_DIR /data/releases Persistent signed APK directory
MEMBY_RELEASE_PUBLISH_TOKEN empty Enables the CI-only release upload endpoint
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_SONARR_URL empty Sonarr address reachable by the gateway; empty disables integration
MEMBY_SONARR_API_KEY empty Sonarr Settings → General → Security API key
MEMBY_SONARR_TTL 5m Shared Redis lifetime for today's calendar
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_MAX_CLIENTS_PER_USER 1 Strict maximum number of distinct signed-in TVs per Emby user
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.