Big changes

This commit is contained in:
ponzischeme89
2026-07-29 15:26:27 +12:00
parent 8d6cf2f5a1
commit 70914400b4
62 changed files with 7501 additions and 744 deletions
+125 -16
View File
@@ -13,13 +13,13 @@ pointing at Emby itself; only metadata and artwork traverse the gateway.
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
curl localhost:32768/readyz
```
Then build the TV app against it:
```powershell
.\gradlew.bat assembleDebug -Pmemby.gatewayUrl=http://<host>:8080
.\gradlew.bat assembleDebug -Pmemby.gatewayUrl=https://mserver.sublogue.com
```
Leaving `memby.gatewayUrl` blank keeps the app on its original direct-to-Emby path, so a
@@ -35,6 +35,26 @@ go run ./cmd/memby-server # needs Postgres + Redis reachable
On Windows, `go` may need `-buildvcs=false` when the working tree has no usable `.git`.
## Deploy to the NAS
```powershell
.\deploy-server.ps1 # from the repo root; PowerShell 7
```
Streams the local `server/`, `docker-compose.yml` and `.env.example` to the NAS over one
SSH connection, builds there, and swaps the release in only once
Postgres, Redis and the server all report healthy — otherwise the previous release is put
back. The Postgres volume survives every deployment.
Configuration comes from the local `.env.example`, which holds real values: each deployment
installs it as the NAS's `.env`, keeping the previous one as `.env.previous`. It validates
the fixed `32768` reverse-proxy port and required admin/Emby secrets before activation.
Changing `POSTGRES_PASSWORD` intentionally recreates the PostgreSQL volume with an empty
database and the new password.
It deploys the **local working tree**, including uncommitted server changes.
`-SourceDirectory`, `-Destination`, `-RemoteHost` and `-HealthTimeoutSeconds` cover the rest.
## Logs
The server writes one compact, structured text line per event, designed for
@@ -58,14 +78,19 @@ headers attached.
| 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/status` | Lightweight live maintenance state plus informational alerts; 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/for-you?minutes=` | Tracearr-powered, explainable picks for a 0/30/60/120-minute viewing window |
| 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/search?q=&limit=` | Library search (title, series name, year, genre, studio) |
| GET | `/v1/search/history` | Current user's distinct searches from the last 30 days |
| POST | `/v1/search/history` | Record a successful search for the current user |
| GET | `/v1/items/{id}` | Full metadata for one item |
| GET | `/v1/items/{id}/episodes` | Complete episode browser for a series, grouped into seasons by the TV |
| GET | `/v1/items/{id}/playback` | Resolves series → episode, returns a direct-play URL |
| GET | `/v1/items/{id}/next` | Episode following this one, or 404 when nothing does |
| GET | `/v1/items/{id}/trailer` | First local trailer, or 404 |
| POST | `/v1/items/{id}/favorite` | `{"value":true}` |
| POST | `/v1/items/{id}/played` | `{"value":true}` |
@@ -77,7 +102,7 @@ headers attached.
### 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:
three fixed rows repeated flat for the client's offline cache:
```json
{
@@ -86,21 +111,21 @@ four fixed rows repeated flat for the client's offline cache:
{"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": "latest-movies", "title": "Recent New Releases", "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": [...],
"continueWatching": [...], "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
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.
### Recommendations
@@ -138,6 +163,49 @@ Curated shelves require at least one completed library import because their genr
filter runs against Postgres. Emby's exact studio naming is preserved during import;
Apple matching accepts `Apple TV+`, `Apple TV Plus` and `Apple Studios`.
### Tracearr-powered For You
`/v1/for-you` is separate from `/v1/home`, so the launcher remains a single fast request
and never waits for analytics. The Android rail loads For You only when opened and lets
the viewer choose 30 minutes, one hour, two hours or any length.
The gateway combines:
- Emby history, favourites and catalogue metadata;
- Tracearr's read-only public `/api/v1/public/history` data, including completion,
aggregate watch time, device/platform, codecs and direct-play/transcode outcomes;
- Memby's own recent card focus, dwell and selection events;
- the requested time window and the item's Emby runtime.
Tracearr currently exposes no per-user or `since` history query. A background importer
therefore pages newest-first and upserts recommendation-relevant fields by Tracearr's
stable `(serverId, sessionId)` key. Incremental passes run every five minutes and stop
after two unchanged pages (at most ten); a daily full pass catches late or out-of-order
updates and reconciles deletions. Imports are idempotent.
Postgres retains the compact source sessions, one derived profile per enabled Emby user,
and every eligible ranked Movie/Series candidate. The background builder reads Tracearr's
public users endpoint and exact-matches usernames against Emby's user list. Unmatched
Emby users still receive Emby-history recommendations; similar-looking names are never
fuzzily joined without an explicit operator decision.
Keeping the pool deliberately much larger than the cards shown gives the request-time
`minutes` filter enough headroom. `/v1/for-you` reads up to 240 ranked candidates and
derives several de-duplicated shelves: top picks, up to two distinct “Because you
finished …” shelves, up to two genre shelves, and a television-compatible shelf when
there is enough playback evidence. Completed-title evidence is distributed
deterministically across relevant candidates rather than allowing the newest Drama
title to explain every Drama recommendation.
The endpoint remains one indexed PostgreSQL read plus JSON enrichment. Pools may be
30 minutes stale; rebuilds inside a ten-minute window are coalesced. 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
`MembyCompatibility`. The TV shows the reason on the card and in the focused metadata
panel. Missing Tracearr or sparse codec evidence degrades to Emby/Memby signals rather
than blanking the area; Tracearr errors never affect the essential home rows.
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
@@ -156,20 +224,52 @@ Sonarr poster and fanart requests are proxied through the gateway; its API key i
sent to the TV. The shared Redis entry is refreshed every five minutes by default, not
once per user.
### Aired banners
The same calendar data drives a slide-in banner on open clients: when an episode's air
time passes and Sonarr has not imported it yet, `/v1/status` starts returning an alert —
```json
{"maintenance": false, "message": "", "alerts": [
{"id": "sonarr:7:42:aired", "kind": "sonarr-aired", "title": "Northbound",
"message": "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
"itemId": "sonarr:7:42", "imageTag": "sonarr", "airedAt": "2026-07-27T21:00:00+12:00"}]}
```
`/v1/status` is the poll the app already runs every ten seconds, so this needs no push
channel and no second connection. Alerts read from the cached calendar, so a polling
client never costs a Sonarr request of its own; they are newest-first and capped at three.
Episodes already downloaded and unmonitored ones say nothing — the first is on the home
screen already, the second is never coming.
The server has no idea which TVs have seen what, so the client owns that: it persists the
ids it has shown and displays each alert once, for ten seconds, without taking focus — a
ring on the banner counts that down, since the viewer cannot dismiss it by remote.
Clients poll only while a Memby screen is in the foreground, and record an alert as shown
only when the banner is actually on screen. So keep offering an alert for the whole window
rather than once: a TV that was showing its screensaver when the episode aired will pick
the alert up when someone comes back to it, and one that never does simply lets the alert
expire with the window.
`MEMBY_SONARR_ALERT_WINDOW` bounds how long after air time an alert stays current;
`0` disables banners while leaving the airing-today row alone.
## Admin interface
`http://<host>:8080/admin/` — a single self-contained page for library imports, the
`https://mserver.sublogue.com/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.
`/admin` route 404s so it cannot be left exposed by accident. Loading the page establishes
a persistent HttpOnly admin cookie automatically, so the configured token survives server
and browser reboots without being pasted into the page. Anyone who can load this local-only
page is therefore an admin: put the whole path behind your reverse proxy's own auth before
making the gateway reachable 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/for-you` | `{"action":"incremental-import"}`, `{"action":"full-import"}`, or `{"action":"rebuild-all"}` |
| 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 |
@@ -301,8 +401,10 @@ re-reads Emby rather than serving a row it just contradicted. Partial home paylo
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
Postgres is the durable half: it holds gateway sessions, the imported Emby catalogue,
Tracearr's compact recommendation history, derived profiles and prepared candidate pools.
Losing Redis costs a cold cache; losing Postgres signs everyone out and requires catalogue
and recommendation backfills. A gateway 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.
@@ -314,13 +416,19 @@ ID; signing the same TV in again rotates its token, while a new TV is rejected o
| `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_LISTEN_ADDR` | `:8080` outside Compose; `:32768` in the NAS stack | |
| `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_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_FOR_YOU_MIN_REBUILD_AGE` | `10m` | Coalesces bursts of pool invalidations |
| `MEMBY_FOR_YOU_REFRESH_INTERVAL` | `30m` | Acceptable prepared-pool staleness |
| `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 |
@@ -331,6 +439,7 @@ ID; signing the same TV in again rotates its token, while a new TV is rejected o
| `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_SONARR_ALERT_WINDOW` | `3h` | How long after air time an "aired" banner stays current; `0` disables banners |
| `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 |