Files
memby/CLAUDE.md
T
ponzischeme89andClaude Opus 5 62f6345a40 Server-controlled app updates, optional or forced
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; the verdict is none, optional or mandatory.

- internal/appupdate holds the decision as pure, tested logic: below
  minimumVersion is mandatory, below latestVersion is optional.
- Admin page gains an App updates section — latest version, APK URL,
  notes, and a "Require this update" toggle that sets the forced floor.
- Client shows a dismissable prompt for optional, and a full-screen
  panel that swallows Back for mandatory. Instructions say what the
  system installer will ask before it asks.

Two safeguards: a client that cannot report its version is never forced,
and the client ignores a verdict with no download URL, so a
half-configured policy cannot produce an unblockable screen with a dead
button. An unreachable gateway shows nothing.

The verdict is deliberately not part of /v1/home: that payload is cached
per user, while this answer depends on the requesting client's version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:34:04 +12:00

15 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. 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.

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.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.