Files
memby/CLAUDE.md
T
ponzischeme89andClaude Opus 5 2ce405c540 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>
2026-07-27 08:16:20 +12:00

13 KiB

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.

.\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/):

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.gatewayUrlMEMBY_GATEWAY_URL. Non-blank puts the app in gateway mode.
  • memby.serverUrlEMBY_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.