Files
memby/CLAUDE.md
T

366 lines
24 KiB
Markdown
Raw Normal View History

# 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)
```
2026-07-29 15:26:27 +12:00
**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`,
2026-07-27 08:22:55 +12:00
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.
2026-07-29 15:26:27 +12:00
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.
2026-07-27 08:22:55 +12:00
`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.
2026-07-29 15:26:27 +12:00
- 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.
2026-07-29 15:26:27 +12:00
**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`.
2026-07-29 15:26:27 +12:00
**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.
2026-07-29 15:26:27 +12:00
**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.