2026-07-27 08:16:20 +12:00
|
|
|
# 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`.
|
|
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
## 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.
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
## 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 |
|
|
|
|
|
| --- | --- | --- |
|
2026-07-27 21:06:51 +12:00
|
|
|
| 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 |
|
2026-07-27 08:16:20 +12:00
|
|
|
| POST | `/v1/auth/logout` | Retire this device's token |
|
|
|
|
|
| GET | `/v1/auth/session` | Confirm a stored token is still valid |
|
2026-07-27 21:06:51 +12:00
|
|
|
| GET | `/v1/status` | Lightweight live maintenance state; remains available during maintenance |
|
2026-07-27 08:16:20 +12:00
|
|
|
| GET | `/v1/home?limit=` | **Every launcher row in one response** |
|
|
|
|
|
| GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild |
|
2026-07-27 08:34:04 +12:00
|
|
|
| GET | `/v1/update` | Whether this client (per `X-Memby-Version`) must update |
|
2026-07-27 08:16:20 +12:00
|
|
|
| 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": [...]},
|
2026-07-27 21:06:51 +12:00
|
|
|
{"id": "sonarr-airing-today", "title": "Shows airing today", "kind": "schedule", "items": [...]},
|
2026-07-27 08:16:20 +12:00
|
|
|
{"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": [...]},
|
2026-07-27 21:06:51 +12:00
|
|
|
{"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": [...]}
|
2026-07-27 08:16:20 +12:00
|
|
|
],
|
|
|
|
|
"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.
|
2026-07-27 21:06:51 +12:00
|
|
|
- **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.
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
Rows shorter than four items are dropped, and a user with no history gets no rows at all
|
2026-07-27 21:06:51 +12:00
|
|
|
rather than a strip of noise, except curated shelves: these remain useful with their
|
|
|
|
|
quality-ranked fallback.
|
2026-07-27 08:16:20 +12:00
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
**The home screen never waits on the engine.** Rows live in their own `r:<userId>:rows:v2`
|
2026-07-27 08:16:20 +12:00
|
|
|
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.
|
|
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
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`.
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
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.
|
|
|
|
|
|
2026-07-27 21:06:51 +12:00
|
|
|
### 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.
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
## 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":"…"}` |
|
2026-07-27 08:34:04 +12:00
|
|
|
| POST | `/admin/api/update-policy` | `{"enabled":true,"latestVersion":"0.1.54","downloadUrl":"…","required":false}` |
|
2026-07-27 08:16:20 +12:00
|
|
|
| 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
|
2026-07-27 21:06:51 +12:00
|
|
|
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.
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
2026-07-27 08:34:04 +12:00
|
|
|
## App update policy
|
|
|
|
|
|
|
|
|
|
The gateway decides whether a TV may keep running its current build. Clients send
|
2026-07-27 21:06:51 +12:00
|
|
|
`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:
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
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.
|
2026-07-27 08:34:04 +12:00
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
2026-07-27 08:16:20 +12:00
|
|
|
## 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,
|
2026-07-27 21:06:51 +12:00
|
|
|
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.
|
2026-07-27 08:16:20 +12:00
|
|
|
|
|
|
|
|
## 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` | |
|
2026-07-27 21:06:51 +12:00
|
|
|
| `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 |
|
2026-07-27 08:16:20 +12:00
|
|
|
| `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 |
|
2026-07-27 21:06:51 +12:00
|
|
|
| `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 |
|
2026-07-27 08:16:20 +12:00
|
|
|
| `MEMBY_SYNC_INTERVAL` | `1h` | Incremental import cadence; `0` disables |
|
|
|
|
|
| `MEMBY_SYNC_TIMEOUT` | `30m` | Bounds one import |
|
|
|
|
|
| `MEMBY_SYNC_ON_START` | `false` | Import at boot |
|
2026-07-27 21:06:51 +12:00
|
|
|
| `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 |
|
2026-07-27 08:16:20 +12:00
|
|
|
| `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 |
|
2026-07-27 21:06:51 +12:00
|
|
|
| `MEMBY_MAX_CLIENTS_PER_USER` | `1` | Strict maximum number of distinct signed-in TVs per Emby user |
|
2026-07-27 08:16:20 +12:00
|
|
|
| `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.
|