Files
memby/CLAUDE.md
T
2026-07-29 15:26:27 +12:00

366 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
**Memby** — an Android TV (Leanback) Emby client by **ponzischeme89**, written in Kotlin +
Compose for TV. It contains two surfaces over one shared data layer: the **client app**
(setup → profile chooser → home → player) and the **system screensaver** (`DreamService`,
labelled "Memby Screensaver"), which was the project's original purpose and still ships in
the same APK.
User-facing name is always **Memby**: `app_name`/`screensaver_name`/`developer_name` in
`res/values/strings.xml`, the `MediaBrowser Client="Memby"` auth header Emby shows in its
devices list, and on-screen copy.
`Emby*` class names (`EmbyRepository`, `EmbyApi`, `EmbyServiceFactory`, `EmbyModels`) are
kept on purpose: those types model *Emby's* API, and renaming them would make the code
lie about what it talks to. App-identity types are `Memby*`.
## Repository layout
This is a two-language monorepo. `app/` and `benchmark/` are the Gradle build; `server/`
is an independent Go module (the **Memby gateway**) that Gradle does not know about, built
and run through Docker. `docker-compose.yml` at the root wires the gateway to Postgres and
Redis. The two halves are coupled only by an HTTP contract — see "Gateway mode" below.
## Build, install, test
Requires JDK 17 and the Android SDK. `deploy-debug.ps1` sets `JAVA_HOME` to Android Studio's
bundled JBR; do the same when invoking Gradle directly if the shell JDK isn't 17.
```powershell
.\gradlew.bat assembleDebug # build APK -> app/build/outputs/apk/debug/
.\gradlew.bat test # JVM unit tests (app/src/test)
.\gradlew.bat :app:testDebugUnitTest --tests "*MediaBadgesTest" # one test class
.\gradlew.bat installDebug
.\deploy-debug.ps1 -Serial 192.168.20.3:41479 # force-stop, install, wake, relaunch
.\gradlew.bat :benchmark:connectedCheck # macrobenchmarks; needs a connected TV
```
For the gateway (from `server/`):
```bash
go build ./... && go test ./... # add -buildvcs=false on Windows if .git is unusable
docker compose up -d --build # from the repo root; needs .env (see .env.example)
```
**Deploying the gateway to the NAS** is `deploy-server.ps1` (PowerShell 7):
```powershell
.\deploy-server.ps1 # local tree -> 10.0.0.213:/share/Docker/Memby
.\deploy-server.ps1 -SourceDirectory C:\src\memby -Destination /share/Docker/Memby-test
```
It tars the local `server/`, `docker-compose.yml` and `.env.example`, and streams them over
one SSH connection (interactive password; stdin carries the
archive, so OpenSSH prompts on the tty). The remote half stages into
`<destination>.new.$$`, builds, then swaps directories and waits for all three health
checks, restoring the previous release if anything fails. The named Postgres volume is
preserved — it never runs `compose down -v`.
**`.env.example` is the configuration.** It holds real values, and every deployment
overwrites the NAS's `.env` with the local copy (the old one is kept beside it as
`.env.previous`). The script requires `MEMBY_PORT=32768`, `MEMBY_ADMIN_TOKEN`,
`MEMBY_EMBY_URL` and `POSTGRES_PASSWORD` before activation, then confirms the admin token
reached the running container. The database volume is always preserved; deployment stops
before activation if the Postgres password differs from the deployed value, because a
credential change requires an explicit database migration. The local working tree is
deployed directly; no commit or push is required.
`local.properties` must contain `sdk.dir=...` when building from the CLI.
Lint has `abortOnError = false` (media3's `@UnstableApi` opt-in check would otherwise fail
the build), so lint failures do not surface at build time.
Unit tests are plain JUnit 4 with no Android/Robolectric dependency — logic that needs
testing must live in a pure function or a plain data class (`mediaBadges`, `HomeUiState`,
`millisecondsToTicks`, `ringColorFromHex`, `EmbyProfile` handling are the existing examples).
## Identity
One name everywhere: **`com.ponzischeme89.memby`** is the Kotlin package, the Gradle
`namespace` and the `applicationId`. Identity types are `Memby*` (`MembyApp`,
`MembyDreamService`, `Theme.Memby`).
Historical note, because old APKs and TVs still carry it: through v0.1.52 the package was
`com.mattcohen.embyscreensaver` and the `applicationId` was `com.mattcohen.embyclientsname`.
Both changed in v0.1.53. **`applicationId` is the install identity** — changing it makes
every TV treat the build as a brand-new app: the old icon stays until uninstalled, the
DataStore session is gone, and users sign in again. Treat any future change to it as a
migration, not a rename. adb commands, the benchmark `packageName` and `FileProvider`
authorities all derive from it.
**Versioning.** `versionCode` is derived from `versionName`: `major*10000 + minor*100 +
patch` (0.1.53 → 153). Bump both together — the in-app updater compares `versionName`,
while Android refuses an APK whose `versionCode` went backwards. `release.ps1 -Version`
rewrites both, so prefer it over editing the build file by hand.
**Releases.** APKs are self-hosted (NAS or any web server), not on a store. `release.ps1`
builds a signed APK and assembles `dist/out/``index.html` (landing page from
`dist/template/`), `latest.json` (the manifest the app polls) and the versioned APK.
Release signing reads `memby.keystore` and friends from `local.properties`; with no
keystore the build still succeeds but emits an unsigned APK and logs a warning. The key
matters more than the code: Android identifies an app by applicationId **plus** signing
key, so a changed key forces every user to uninstall and reinstall.
For direct TV deployment without publishing a release, `deploy-tv.ps1` builds and verifies
the signed release, connects over wireless ADB, installs it with `-r`, and launches the
Leanback activity. It reads the same signing settings from the current user's persistent
`MEMBY_KEYSTORE*` environment variables and defaults to the living-room Chromecast endpoint;
pass `-Device host:port` when Android rotates the wireless-debugging port.
`UpdateChecker` supports two sources, chosen by URL shape in `isManifestUrl` — a `.json`
URL is a static manifest, anything else is a Gitea host. `resolveApkUrl` lets a manifest
use a relative `apkUrl`. Both are unit-tested in `UpdateSourceTest`.
**Forced updates are server-controlled.** `server/internal/appupdate` decides `none` /
`optional` / `mandatory` from the client's `X-Memby-Version` header against an
operator-set policy (admin page → App updates). `HomeViewModel.checkForAppUpdate` runs on
every launch and `ui/UpdateScreen.kt` renders the verdict — mandatory covers the whole
home screen with `zIndex(10f)`, swallows Back and offers no dismiss. Two safeguards worth
preserving: a client with an unreadable version is never forced (it could not escape the
prompt), and the client ignores any verdict without a `downloadUrl` (`isActionable`), so a
half-configured policy cannot produce a blocking screen with a dead button. The verdict
must stay out of `/v1/home`, which is cached per user while this answer varies per client
build.
## Architecture
**Manual DI.** `ServiceLocator` (initialised in `MembyApp`) holds the single `SettingsStore`
and `EmbyRepository`. Activities, composables and `MembyDreamService` all read from it —
there is no DI framework and no per-screen repository construction.
**Backend selection is build-time config.** Two Gradle properties in `gradle.properties`
become `BuildConfig` fields, both read through `data/ServerConfig.kt`:
- `memby.gatewayUrl``MEMBY_GATEWAY_URL`. Non-blank puts the app in **gateway mode**.
- `memby.serverUrl``EMBY_SERVER_URL`. The Emby address for the direct path; when set,
the repository's `activeServerUrl` prefers it over the persisted `Settings.serverUrl`
and `SetupScreen` hides the address field.
Prefer `activeServerUrl` over `snapshot.serverUrl` in new repository code, or a hardwired
build silently falls back to a stale saved address. `resolveServerUrl` holds the
precedence rule as a pure function so it can be unit-tested.
**Gateway mode.** `EmbyRepository` is dual-path: every method starts with a
`if (ServerConfig.isGateway)` branch that calls `GatewayApi`, then falls through to the
original Emby code. Both paths must keep working — the direct path is the fallback when
the container is down. Specifics worth knowing:
- The gateway forwards **Emby's item JSON verbatim**, so `BaseItem` is the single item
model in both modes. Only the envelope differs (`data/model/GatewayModels.kt`).
- `Settings.token` holds the *gateway* token in gateway mode and the Emby token
otherwise; `Settings.serverUrl` likewise holds whichever backend was signed into. No
separate storage slots.
- `supportsBatchHome` drives `HomeViewModel`: gateway mode fetches all four rows with one
`getHome()` call, direct mode keeps the four-way parallel fan-out.
- **Rows are server-composed.** `/v1/home` returns a `rows` array (id, title, kind, items)
and `MainActivity.serverHomeRows()` renders it verbatim, so a new row type ships without
an app release — an unknown `kind` falls back to poster cards rather than disappearing.
`state.rows` is empty on the direct path, where `homeRowsFor()` composes rows locally.
Two things are easy to miss: rows hold their own copies of items, so
`HomeViewModel.updateUserData` must map over `rows` too or an optimistic favourite won't
show on a recommendation card; and `loadBatchHome` keeps the previous rows when a
response arrives with none, because the gateway omits recommendations while they build.
- `HomeCache.rows` persists them for cold start. New fields there need defaults — an
existing install decodes a cache written by the previous build.
- Image URLs are built by the private `imageUrl()` helper. Coil fetches plain URLs with no
interceptor, so the credential rides in the query string either way — `t=` for the
gateway proxy, `api_key=` for Emby.
- Video always direct-plays from Emby. The gateway returns a URL; it never proxies a
stream. Don't route playback through it.
- Search is dual-path like the rest: `/v1/search` on the gateway (Postgres full-text,
falling back to Emby before the first import), `SearchTerm` on `Users/{id}/Items`
directly. `ui/search/` renders it — see "Search" below.
The wire contract is pinned from both ends: `GatewayPayloadTest.kt` / `ServerHomeRowsTest.kt`
(Kotlin) and `internal/api/api_test.go` (Go). Change a field name or a row `kind` and one
of them should fail.
**Imported library.** `server/internal/library` copies Emby's catalogue into
`library_items` (payload stored verbatim as JSONB, hot fields promoted to columns for
filtering plus a generated `tsvector`). Search and the recommendation candidate pool read
from it, falling back to Emby when it is empty — so both paths must keep working. It is
imported with `EnableUserData=false` on purpose: the table is shared by the whole
household, so watched/favourite/resume state must never be cached there and still comes
from Emby live. A full import mark-and-sweeps on `synced_at`; incremental uses
`MinDateLastSaved` with a minute of overlap.
**Maintenance mode** gates the whole `/v1` subtree (that's why `Routes()` builds a
separate `v1` mux) with a 503 carrying `maintenance: true`. `/healthz`, `/readyz` and
`/admin` sit outside it deliberately. State lives in Postgres and is cached in memory,
re-read every 30s. Client side, `parseMaintenanceMessage` pulls the operator's message out
of the 503 body (trusting only the known `message` field, truncated) and
`HomeUiState.maintenanceMessage` — distinct from `statusMessage`, which is the ordinary
slow-connection banner — swaps the whole content area for `ui/MaintenanceScreen.kt`. The
navigation rail stays mounted beside it so Settings and Switch user still work, and the
retry button takes `contentFocusRequester` (with `focusProperties { left = … }` back to
the rail) because otherwise D-pad focus has nowhere to go once the rows are gone.
**Service alerts.** `/v1/status` is the only thing an open app polls continuously (10s,
`MaintenanceMonitor`), so it doubles as the push channel: alongside maintenance state it
carries an `alerts` array, and `ui/ServiceAlertBanner.kt` drops one in as a full-width bar
across the top of the screen, broadcast-notice style (it spans the navigation rail too).
The only producer today is `api/alerts.go` — an episode whose Sonarr air time has passed
but which Emby has not imported yet ("aired, coming soon"). It reads the *cached*
airing-today calendar, so polling clients never cost a Sonarr request. Things to preserve:
the server has no idea which TVs saw what, so the client dedupes by id against
`SettingsStore.markAlertSeen` (persisted, or every relaunch replays yesterday's news); an
alert is only *offered* until the banner calls `alertShown` — nothing is persisted and no
dismissal timer runs before that, so one arriving behind the screensaver waits rather than
being consumed by nobody, and `pendingAlertExpired` drops it once the gateway stops
offering it. The status loop itself runs under
`ProcessLifecycleOwner … repeatOnLifecycle(STARTED)`, so a backgrounded app stops polling
entirely instead of hitting the gateway every 10s at a TV nobody is watching. The banner is
never focusable and
times itself out after `MaintenanceMonitor.ALERT_VISIBLE_MS` (10s, with a ring counting it
down — take the duration from that constant, or the ring and the timer drift apart),
because stealing D-pad focus mid-browse is worse than a missed notice;
and alerts are suppressed under maintenance and under a mandatory update, which own the
screen. `MEMBY_SONARR_ALERT_WINDOW=0` turns them off without touching the schedule row.
**Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)`
`trim``distinctUntilChanged``collectLatest { repository.search(it) }` — and
`collectLatest` is the load-bearing part: it cancels the in-flight request, so a slow
response for a prefix can never overwrite the results for what was typed after it.
Searching starts at two characters (`shouldSearch`); one letter matches half a library.
`rankSearchResults` is a pure, stable sort that only lifts exact/prefix/word-boundary
title matches above the backend's own relevance order — it never re-sorts alphabetically,
and it keeps weak matches rather than showing an empty pane. A small access-ordered map
caches results per query for the session, so backspacing is instant.
Focus is the hard part and is explicit: the leftmost keyboard column goes to the rail, the
rightmost goes to the results grid, the grid's first column goes back to the *last key
used* (a `FocusRequester` attached to whichever key that is), and the grid has a
`focusRestorer`. Back moves results → keyboard → clear query → leave, one step per press.
Physical keyboards and phone-remote apps feed the same state through one
`onPreviewKeyEvent` that consumes only printable characters and backspace — D-pad and Back
must fall through. The voice button needs the `android.speech.RecognitionService` entry in
the manifest's `<queries>`, or `isRecognitionAvailable` returns false on Android 11+ and
it hides itself on devices that actually support it.
**Row analytics.** `data/analytics/RowAnalytics.kt` buffers impression/focus/select events
with dwell timing (injectable clock, unit-tested) and `HomeViewModel` flushes every 20s,
on `ON_STOP`, and on dispose. Fire-and-forget by design — `reportRowEvents` swallows
failures, because telemetry must never surface on a TV. Aggregates are read at query time
in `store.RowStats`; raw events are pruned after 90 days.
**Admin interface** is `server/internal/api/admin.html`, a single embedded page (no build
step, no CDN — a strict no-dependency page is the whole point). It polls
`/admin/api/status` every 5s. Guarded by `MEMBY_ADMIN_TOKEN`; unset means every `/admin`
route 404s.
**Recommendations** live in `server/internal/recommend`: `profile.go` is pure scoring
(recency-weighted genre/studio affinity, exclusion of anything seen) and `engine.go` does
the Emby fan-out. Both are unit-tested without a network — `engine.go` takes a narrow
`Source` interface so tests inject a fake. The engine never runs on the home request path:
rows come from the `r:<userId>:rows` cache, and a miss triggers a deduplicated background
rebuild while home returns immediately. That key is intentionally outside the `u:`
namespace that mutations wipe; only a finished playback retires it.
**`EmbyRepository`** is the only place that talks to Emby. It keeps a `@Volatile` `snapshot`
of `Settings` collected from DataStore so synchronous callers (URL builders,
`rotationIntervalMillis`) don't suspend, and it caches the Retrofit `EmbyApi` instance,
rebuilding only when the base URL changes. All image and stream URLs are built here with
`api_key` appended. Errors reaching the UI go through `friendlyEmbyError` — never surface
raw HTTP bodies, which can contain tokens (the OkHttp logging interceptor is pinned at
`Level.NONE` for the same reason).
**Emby query conventions.** List endpoints request the narrowest `Fields` /
`EnableImageTypes` set that the row needs (`getHomeItems` enforces this); full metadata is
fetched only via `getItemDetails` after D-pad focus settles (140 ms debounce in
`HomeViewModel.focusItem`, with an LRU cache and cancellation of the in-flight job). Adding
fields to a home query is a startup-cost regression — extend the detail call instead.
Emby time values are 100-ns ticks; convert at the boundary (`millisecondsToTicks`,
`resumePositionMs`).
**Multi-profile session state.** `SettingsStore` stores a list of `EmbyProfile` (server,
token, userId, plus that profile's cached home JSON) *and* mirrors the active profile into
the flat top-level keys the rest of the app reads. `switchProfile`/`saveSession` must keep
both in sync; `legacyProfile()` synthesises a profile from the flat keys for installs that
predate the list. `deviceId` is intentionally preserved across `clearSession()`.
**Home startup path.** `HomeCache` (last successful home response) is persisted per profile
and used as the initial `HomeUiState`, so the launcher renders rows before the network
returns; sections then refresh in parallel under a `Mutex` and re-persist. Playback stops
are broadcast through `repository.playbackStops` and refresh only the Continue/Next-Up rows.
**Screensaver hosting.** `ScreensaverContent` is shared by `MembyDreamService` and
`ScreensaverActivity`. A `DreamService` is not a `ComponentActivity`, so
`DreamLifecycleOwner` supplies the ViewTree lifecycle/ViewModelStore/SavedState owners
Compose requires; D-pad handling lives in the composable while the hardware Play/Pause key
is intercepted in `dispatchKeyEvent` and routed via the `ScreensaverActions` holder.
Playback from the dream `finish()`es first and starts `PlayerActivity` on a delayed main-
thread post to avoid the "activity behind the dream" race.
**In-app updates.** `UpdateChecker` polls a user-configured **Gitea** release
(`/api/v1/repos/{owner}/{repo}/releases/latest`, token auth for private repos), downloads the
APK and hands it to the system installer via `FileProvider`. Because replacing the APK kills
a running Dream and leaves a black surface, `UpdateRecoveryReceiver` catches
`MY_PACKAGE_REPLACED` and relaunches `MainActivity` with
`EXTRA_LAUNCH_UPDATED_SLIDESHOW`.
**Playback** uses Emby's direct stream (`/Videos/{id}/stream?static=true`) — no
`PlaybackInfo`/transcode negotiation, so exotic codecs may fail. The
`media3-exoplayer-hls` dependency is already present for when that's added. Progress is
reported back to Emby via `reportPlaybackStarted/Progress/Stopped`.
**Next up / auto-advance.** 30 s before an episode ends, `PlayerActivity` slides up
`player_next_up_banner.xml` and rolls into the next episode when it reaches zero (Settings
→ Playback turns it off; `Settings.autoPlayNextEpisode`). Which episode that is comes from
`repository.nextEpisode`, dual-path like everything else: `/v1/items/{id}/next` on the
gateway, `Shows/{seriesId}/Episodes?AdjacentTo=` directly. Both rely on Emby returning
`[previous, current, next]` in running order, so it is the *position* of the current
episode that identifies the next one — never the length of the list, which shrinks at both
ends of a season (`episodeAfter` in `playback.go`, unit-tested). Three things are easy to
break: the countdown is driven off the playhead, not a timer of its own, so pausing holds
it and seeking backwards out of the window re-arms it; advancing swaps the `MediaItem`
inside the running player instead of relaunching the activity, so `itemId`/`playbackStarted`
/`stopReported` must all be reset together or the outgoing episode is never reported
stopped; and a movie simply resolves to null, which is why nothing special-cases item type.
**Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to
tag `EmbyClientPerf`; `benchmark/` is a `com.android.test` macrobenchmark module currently
targeting the debug build (`suppressErrors = DEBUGGABLE`), so its numbers are
debug-influenced.
## UI conventions
Use `androidx.tv.material3` components (`Button`, `Card`, `Text`) rather than the phone
Material 3 ones. `MainActivity.kt`, `HomeComponents.kt` and `ScreensaverContent.kt` are the
three large files — new screens generally belong in `ui/<feature>/` rather than growing
them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`);
everything must be reachable by D-pad only.
**Animations must not recompose.** This app ships to weak TV boxes, so an animated value
read in a composable body — `val x by animateFloat(...)` then using `x` in the layout — is
a bug: it recomposes that whole scope every frame. Pass the value down as a lambda and
read it inside a `Canvas`/`drawBehind` block (draw phase only), and derive any text from it
with `derivedStateOf` so it recomposes when the *displayed* value changes, not when the
float does. `ServiceAlertBanner`'s countdown ring and pulse are the worked example: ~10
recompositions of one number over ten seconds instead of ~600 of the whole bar. The same
rule applies to collecting flows — collect in the smallest composable that needs the value,
not at the top of `MainActivity`, or every emission recomposes the launcher.
**Previews.** `ui/PreviewSupport.kt` holds the one preview shape: `@TvPreview` (1080p TV,
landscape, launcher black) plus `PreviewSurface { }` for the real theme. Use those rather
than a bare `@Preview`, which defaults to a phone and misrepresents every layout here.
A preview does not run `ServiceLocator`, so only composables that take their state as
parameters are previewable — the same property that makes them unit-testable. Prefer
previewing the still inner composable over an animated wrapper (`AlertBanner`, not
`ServiceAlertBanner`): a frozen frame of a slide-in shows nothing useful.
**Screenshots.** `app/src/test/.../ServiceAlertBannerScreenshotTest.kt` renders composables
to PNGs under `app/build/screenshots/` via Roborazzi + Robolectric, at TV 1080p qualifiers
— the way to look at a layout without a TV to hand. This is the *only* Android dependency
allowed in `app/src/test`; keep it confined to `*ScreenshotTest.kt` files so logic tests
stay pure JUnit. Recording is always on (`roborazzi.test.record` in `testOptions`): these
are artifacts to look at, not checked-in goldens, and a screenshot test that silently
captures nothing is worse than none. AGP's own `com.android.compose.screenshot` plugin was
tried first and discovers zero previews on AGP 8.13.2 — don't re-litigate it without
checking that upstream.