Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+142 -27
View File
@@ -75,10 +75,13 @@ 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/auth/devices` | List this user's signed-in TVs |
| PUT | `/v1/auth/devices/{deviceId}` | Rename a signed-in TV |
| DELETE | `/v1/auth/devices/{deviceId}` | Revoke another signed-in TV |
| GET | `/v1/status` | Lightweight live maintenance state plus informational alerts; remains available during maintenance |
| GET | `/v1/features` | Versioned, capability-gated feature document evaluated for this TV |
| 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 |
@@ -109,7 +112,8 @@ three 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": "for-you:pick-up", "title": "Pick this show up again", "kind": "for-you", "items": [...]},
{"id": "sonarr-airing-today", "title": "Shows airing in the next 5 days", "kind": "schedule", "items": [...]},
{"id": "favorites", "title": "Favourites", "kind": "favorites", "items": [...]},
{"id": "latest-movies", "title": "Recent New Releases", "kind": "latest", "items": [...]},
{"id": "similar:sev", "title": "Because you watched Severance", "kind": "similar", "items": [...]},
@@ -149,12 +153,10 @@ Rows shorter than four items are dropped, and a user with no history gets no row
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
**The home screen never waits on the engine.** Rows live in their own `r:<userId>:rows:v3`
cache key with a long TTL (24h). 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.
work once. Playback and favourite mutations do not discard this slow-moving taste cache.
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.
@@ -184,7 +186,7 @@ after two unchanged pages (at most ten); a daily full pass catches late or out-o
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
and at most 750 ranked Movie/Series candidates per user. 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.
@@ -197,15 +199,24 @@ 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.
The endpoint remains one indexed PostgreSQL read plus JSON enrichment. Tracearr imports
stay frequent but do not rebuild pools. A session's first transition to stopped/completed
dirties only its matched user, and all pools rebuild once daily at the configured local
off-peak hour. A stored algorithm version triggers a one-time startup migration only when
ranking behavior changes. 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.
Tracearr episode history also identifies shows abandoned in season one. A show qualifies
after 21 days without activity only when Emby still returns a season-one `Next Up`
episode, which prevents completed shows and later-season pauses from leaking into the
row. Genuine matches appear on Home as **Pick this show up again**, even when only one
show qualifies; generic recommendations are never used as padding.
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
@@ -214,9 +225,12 @@ 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.
v3 calendar and inserts **Shows airing in the next 5 days** near Continue Watching.
The window is today plus the following four local calendar days. Card timing reads
`In 8 hours (4:00 PM)` for later today, `Tomorrow: 4:00 PM` for tomorrow, and then the
weekday and local time. Cards also show 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.
@@ -252,28 +266,56 @@ rather than once: a TV that was showing its screensaver when the episode aired w
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.
`0` disables banners while leaving the five-day schedule row alone.
### Radarr digital-release schedule
When `MEMBY_RADARR_URL` and `MEMBY_RADARR_API_KEY` are set, the gateway reads Radarr's
v3 calendar and inserts **Upcoming Movie releases** beside the Sonarr schedule near
Continue Watching. The row covers today plus the following four local calendar days.
Radarr's `digitalRelease` date is preferred. When it is missing, the row uses an
estimated digital availability date 30 days after `inCinemas`; physical-disc dates are
ignored. A known digital date always wins, including for older films with later cinema
re-releases.
Movie posters and fanart are proxied through the gateway so the Radarr API key never
leaves the server. `MEMBY_RADARR_TTL` controls the shared calendar cache lifetime.
## Admin interface
`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. 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.
`/admin` route 404s so it cannot be left exposed by accident. The page first uses the same
discreet Emby login gate as the private installer. After successful verification it
establishes the HttpOnly admin cookie, but browser API requests require both that cookie
and the current 30-minute Emby-verified session. The old admin cookie therefore cannot
bypass the gate after the browser session expires. Scripts may continue to use
`Authorization: Bearer <MEMBY_ADMIN_TOKEN>` without a browser session.
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/admin/` | The page |
| GET | `/admin/api/status` | Library counts, sync history, maintenance state |
| GET | `/admin/api/runtime` | Protected Go heap, memory-limit and goroutine metrics |
| 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}` |
| POST | `/admin/api/features` | Publish feature overrides, enter safe mode, reset defaults, or roll back one revision |
| GET | `/admin/api/analytics?days=7` | Row engagement |
The **Features** admin page is the recovery surface for optional TV behaviour. Flags are
registered in the server catalogue and persist only explicit overrides; clearing an
override returns it to its code-owned safe default. Every write is revision-checked and
retains the prior revision. Safe mode disables all optional flags while leaving sign-in,
browsing, playback, maintenance and the admin console operational.
TVs report `X-Memby-Version`, `X-Memby-Protocol`, and a bounded
`X-Memby-Capabilities` list on every request. Protocol and capability checks fail closed,
so a server never advertises an optional contract to a client that did not declare it.
The first migrated controls are the Sonarr preroll, automatic My Shows, and its automatic
follow notification; all three can be stopped without publishing an APK.
## Library import
`internal/library` copies Emby's catalogue into Postgres so the gateway answers from its
@@ -319,7 +361,41 @@ directly in the database works too.
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`.
while left open; the verdict is `none`, `optional` or `mandatory`. This endpoint is
deliberately public and remains available during maintenance: update policy is checked
before login and never reads, validates, or mutates a viewer session.
### First-time TV installation
The gateway hosts a public bootstrap page at:
```text
https://mserver.sublogue.com/install
```
The site root displays the same page. Before any release details or download link are
shown, it asks for an Emby household username and password and verifies them directly
against Emby. Credentials are never stored. The temporary Emby access token is logged
out immediately, and the browser receives a secure, HTTP-only, same-site installer cookie
valid for 30 minutes. This browser login does not create a Memby TV session and therefore
does not appear in the user's signed-in device list.
After authentication, `/updates/latest.apk` redirects to the current immutable, versioned
APK in the persistent `memby-releases` volume. Direct APK requests require either that
short-lived browser session or the per-release signed URL returned to an authenticated
Memby app during its update check; guessed filenames return 404. This is the exact signed
artefact produced and published by `deploy-server.ps1`, not a separate bootstrap build.
The APK is compiled with `memby.gatewayUrl=https://mserver.sublogue.com`, so a fresh
installation opens directly at Memby's login screen.
On a new TV, enter the short `/install` address in a browser or the Downloader app,
download the APK, allow that browser/downloader to install unknown apps if Android asks,
then install and launch Memby. No Files/Storage permission is needed by Memby itself.
The installer is also intentionally absent from search: `/robots.txt` disallows the complete
host, the page and APK responses send `X-Robots-Tag` no-index/no-follow/no-archive
directives, and the HTML repeats those controls for major crawlers. A scanner can discover
that the host exists, but it cannot see a release or download an APK without valid access.
### Automated tagged releases
@@ -353,6 +429,39 @@ 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.
### Publishing an update with `deploy-server.ps1`
The normal NAS deployment can build and publish the TV app in the same operation:
```powershell
.\deploy-server.ps1 -AppVersion 0.1.70 -ReleaseNotes 'Reliable in-app updates'
```
Append `--m` to make the published update mandatory. Older clients receive a blocking
update screen with no skip or dismiss action:
```powershell
.\deploy-server.ps1 -ReleaseNotes 'Required security update' --m
```
It loads the same user-level `MEMBY_KEYSTORE*` variables as `deploy-tv.ps1`, runs the
Android tests, builds the requested release without editing `build.gradle.kts`, verifies
the APK signature, and sends the signed artefact in the SSH deployment archive. After
PostgreSQL, Redis and the replacement gateway are healthy, the remote script publishes
the APK through `POST /admin/api/release`. The gateway records its SHA-256 digest and
byte size in PostgreSQL and serves it from the persistent `memby-releases` volume.
`MEMBY_RELEASE_PUBLISH_TOKEN` must contain a stable, private value in `.env.example`.
Every update intended for installed TVs needs a version higher than the one they already
run; Android rejects equal or lower `versionCode` values. Reusing a version for different
APK bytes is also rejected because release URLs are immutable.
For a server-only emergency deployment, explicitly opt out:
```powershell
.\deploy-server.ps1 -SkipAppRelease
```
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.
@@ -405,8 +514,8 @@ Postgres is the durable half: it holds gateway sessions, the imported Emby catal
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.
ID; signing the same TV in again rotates its token. Accounts have no device cap; viewers
can review and revoke signed-in TVs from the app's Settings screen.
## Configuration
@@ -418,17 +527,21 @@ ID; signing the same TV in again rotates its token, while a new TV is rejected o
| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | |
| `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_LOG_BUFFER_CAPACITY` | `5000` | Bounded in-memory admin event ring; `0` disables capture |
| `MEMBY_GOMEMLIMIT` | `384MiB` | Compose value passed to Go as `GOMEMLIMIT` |
| `MEMBY_SERVER_MEMORY_LIMIT` | `512m` | Compose hard memory ceiling for the server container |
| `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_TTL` | `24h` | 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_FOR_YOU_MIN_REBUILD_AGE` | `24h` | Safety bound for non-forced pool rebuilds |
| `MEMBY_FOR_YOU_REFRESH_INTERVAL` | `24h` | Acceptable prepared-pool staleness |
| `MEMBY_FOR_YOU_REBUILD_HOUR` | `4` | Local hour (023) for the daily prepared 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 |
@@ -440,11 +553,13 @@ ID; signing the same TV in again rotates its token, while a new TV is rejected o
| `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_RADARR_URL` | *empty* | Radarr address reachable by the gateway; empty disables integration |
| `MEMBY_RADARR_API_KEY` | *empty* | Radarr Settings → General → Security API key |
| `MEMBY_RADARR_TTL` | `5m` | Shared Redis lifetime for the five-day digital-release 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