Big changes
This commit is contained in:
+125
-16
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user