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

24 KiB
Raw Blame 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.

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

Deploying the gateway to the NAS is deploy-server.ps1 (PowerShell 7):

.\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.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 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)trimdistinctUntilChangedcollectLatest { 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.