Memby v0.1.53: Android TV client plus gateway
Android TV client for Emby (Kotlin, Compose for TV) and the Memby gateway (Go, Postgres, Redis) that fronts it. Client: - Setup, profiles, home rows, Media3 playback, system screensaver (Dream) - Backend chosen at build time: gateway when memby.gatewayUrl is set, otherwise direct to Emby. Both paths stay working. - Server-composed home rows, rendered verbatim so new row types ship without an app release - Full-screen animated maintenance state, row engagement telemetry Gateway: - One request per TV screen; auth, caching, search and row shaping - Library import from Emby into Postgres (manual, then hourly incremental) - Recommendations from viewing history (recency-weighted genre affinity) - Admin page for imports, an offline switch, and per-row analytics - Video always direct-plays from Emby; only metadata passes through Identity is com.ponzischeme89.memby throughout, replacing com.mattcohen.embyclientsname. A changed applicationId installs as a new app: TVs need a fresh sign-in and the old package uninstalled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
# 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)
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
## 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 exists on the gateway (`repository.search`) but has no UI yet.
|
||||
|
||||
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.
|
||||
|
||||
**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`.
|
||||
|
||||
**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.
|
||||
Reference in New Issue
Block a user