From 2ce405c5404d579c604a85d748756397e979222a Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Mon, 27 Jul 2026 08:16:20 +1200 Subject: [PATCH] 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 --- .env.example | 32 + .gitattributes | 16 + .gitignore | 20 + CLAUDE.md | 224 +++ README.md | 144 ++ app/build.gradle.kts | 118 ++ app/logo.png | Bin 0 -> 869641 bytes app/proguard-rules.pro | 8 + app/src/main/AndroidManifest.xml | 96 ++ .../java/com/ponzischeme89/memby/MembyApp.kt | 34 + .../com/ponzischeme89/memby/ServiceLocator.kt | 23 + .../memby/data/EmbyRepository.kt | 678 ++++++++ .../ponzischeme89/memby/data/ServerConfig.kt | 52 + .../ponzischeme89/memby/data/SettingsStore.kt | 308 ++++ .../memby/data/analytics/RowAnalytics.kt | 153 ++ .../memby/data/model/EmbyModels.kt | 98 ++ .../memby/data/model/GatewayModels.kt | 105 ++ .../memby/data/remote/EmbyApi.kt | 81 + .../memby/data/remote/EmbyServiceFactory.kt | 78 + .../memby/data/remote/GatewayApi.kt | 69 + .../data/remote/GatewayServiceFactory.kt | 54 + .../memby/performance/PerformanceMonitor.kt | 49 + .../memby/screensaver/MembyDreamService.kt | 154 ++ .../ponzischeme89/memby/ui/HomeComponents.kt | 1159 ++++++++++++++ .../ponzischeme89/memby/ui/HomeViewModel.kt | 366 +++++ .../ponzischeme89/memby/ui/MainActivity.kt | 1419 +++++++++++++++++ .../memby/ui/MaintenanceScreen.kt | 337 ++++ .../memby/ui/player/PlayerActivity.kt | 228 +++ .../memby/ui/screensaver/EmbyAppLauncher.kt | 33 + .../ui/screensaver/ScreensaverActivity.kt | 45 + .../ui/screensaver/ScreensaverContent.kt | 997 ++++++++++++ .../memby/ui/settings/SettingsSheet.kt | 420 +++++ .../com/ponzischeme89/memby/ui/theme/Theme.kt | 28 + .../memby/update/UpdateChecker.kt | 155 ++ .../memby/update/UpdateModels.kt | 21 + .../memby/update/UpdateRecoveryReceiver.kt | 28 + app/src/main/res/drawable/app_banner.xml | 16 + app/src/main/res/drawable/emby_logo.png | Bin 0 -> 869641 bytes app/src/main/res/values/strings.xml | 6 + app/src/main/res/values/themes.xml | 13 + app/src/main/res/xml/emby_dream.xml | 6 + app/src/main/res/xml/file_paths.xml | 5 + .../memby/data/GatewayPayloadTest.kt | 109 ++ .../memby/data/MaintenanceMessageTest.kt | 55 + .../memby/data/PlaybackReportMathTest.kt | 16 + .../memby/data/ProfileSettingsTest.kt | 39 + .../memby/data/RowAnalyticsTest.kt | 141 ++ .../memby/data/ServerConfigTest.kt | 31 + .../ponzischeme89/memby/ui/HomeUiStateTest.kt | 39 + .../ponzischeme89/memby/ui/MediaBadgesTest.kt | 35 + .../memby/ui/ServerHomeRowsTest.kt | 128 ++ .../ui/screensaver/SlideProgressMathTest.kt | 39 + benchmark/build.gradle.kts | 44 + benchmark/src/main/AndroidManifest.xml | 4 + .../memby/benchmark/HomeBenchmark.kt | 47 + build.gradle.kts | 8 + deploy-debug.ps1 | 24 + docker-compose.yml | 68 + gradle.properties | 21 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 +++ gradlew.bat | 94 ++ server/.dockerignore | 3 + server/Dockerfile | 21 + server/README.md | 246 +++ server/cmd/memby-server/main.go | 237 +++ server/go.mod | 19 + server/go.sum | 38 + server/internal/api/admin.go | 171 ++ server/internal/api/admin.html | 288 ++++ server/internal/api/admin_test.go | 196 +++ server/internal/api/analytics.go | 102 ++ server/internal/api/api.go | 287 ++++ server/internal/api/api_test.go | 143 ++ server/internal/api/auth.go | 101 ++ server/internal/api/health.go | 49 + server/internal/api/home.go | 285 ++++ server/internal/api/images.go | 65 + server/internal/api/items.go | 104 ++ server/internal/api/maintenance.go | 87 + server/internal/api/playback.go | 159 ++ server/internal/api/recommend.go | 132 ++ server/internal/cache/cache.go | 97 ++ server/internal/config/config.go | 136 ++ server/internal/emby/client.go | 325 ++++ server/internal/library/syncer.go | 342 ++++ server/internal/library/syncer_test.go | 79 + server/internal/recommend/engine.go | 270 ++++ server/internal/recommend/engine_test.go | 230 +++ server/internal/recommend/profile.go | 258 +++ server/internal/recommend/profile_test.go | 183 +++ server/internal/store/analytics.go | 113 ++ server/internal/store/library.go | 181 +++ server/internal/store/schema.sql | 88 + server/internal/store/settings.go | 80 + server/internal/store/store.go | 113 ++ server/internal/store/sync.go | 101 ++ settings.gradle.kts | 27 + 99 files changed, 14433 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/logo.png create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/ponzischeme89/memby/MembyApp.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/ServerConfig.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/analytics/RowAnalytics.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyServiceFactory.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/performance/PerformanceMonitor.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/screensaver/EmbyAppLauncher.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/update/UpdateModels.kt create mode 100644 app/src/main/java/com/ponzischeme89/memby/update/UpdateRecoveryReceiver.kt create mode 100644 app/src/main/res/drawable/app_banner.xml create mode 100644 app/src/main/res/drawable/emby_logo.png create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/emby_dream.xml create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/MaintenanceMessageTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/ProfileSettingsTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/RowAnalyticsTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/data/ServerConfigTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/HomeUiStateTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt create mode 100644 app/src/test/java/com/ponzischeme89/memby/ui/screensaver/SlideProgressMathTest.kt create mode 100644 benchmark/build.gradle.kts create mode 100644 benchmark/src/main/AndroidManifest.xml create mode 100644 benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt create mode 100644 build.gradle.kts create mode 100644 deploy-debug.ps1 create mode 100644 docker-compose.yml create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 server/.dockerignore create mode 100644 server/Dockerfile create mode 100644 server/README.md create mode 100644 server/cmd/memby-server/main.go create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/internal/api/admin.go create mode 100644 server/internal/api/admin.html create mode 100644 server/internal/api/admin_test.go create mode 100644 server/internal/api/analytics.go create mode 100644 server/internal/api/api.go create mode 100644 server/internal/api/api_test.go create mode 100644 server/internal/api/auth.go create mode 100644 server/internal/api/health.go create mode 100644 server/internal/api/home.go create mode 100644 server/internal/api/images.go create mode 100644 server/internal/api/items.go create mode 100644 server/internal/api/maintenance.go create mode 100644 server/internal/api/playback.go create mode 100644 server/internal/api/recommend.go create mode 100644 server/internal/cache/cache.go create mode 100644 server/internal/config/config.go create mode 100644 server/internal/emby/client.go create mode 100644 server/internal/library/syncer.go create mode 100644 server/internal/library/syncer_test.go create mode 100644 server/internal/recommend/engine.go create mode 100644 server/internal/recommend/engine_test.go create mode 100644 server/internal/recommend/profile.go create mode 100644 server/internal/recommend/profile_test.go create mode 100644 server/internal/store/analytics.go create mode 100644 server/internal/store/library.go create mode 100644 server/internal/store/schema.sql create mode 100644 server/internal/store/settings.go create mode 100644 server/internal/store/store.go create mode 100644 server/internal/store/sync.go create mode 100644 settings.gradle.kts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..86060c7 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# Copy to .env next to docker-compose.yml and fill in. Never commit the real .env. + +# How the gateway container reaches Emby. +MEMBY_EMBY_URL=https://molise.bounceme.net + +# What TVs are told to stream from. Only set this when it differs from the address +# above — video goes device -> Emby directly, never through the gateway. +#MEMBY_EMBY_PUBLIC_URL=https://molise.bounceme.net + +# Postgres password for the memby role. Generate one, e.g. +# openssl rand -base64 24 +POSTGRES_PASSWORD=change-me + +# Host port the gateway listens on. +MEMBY_PORT=8080 + +# How long a cached home payload stays warm. +MEMBY_HOME_TTL=60s + +# Admin interface at http://:8080/admin/ — library imports, the maintenance +# switch, and row analytics. Leave blank to disable /admin entirely. Generate with +# openssl rand -hex 32 +MEMBY_ADMIN_TOKEN= + +# Library import. Hourly incremental keeps up with episodes added through the day. +MEMBY_SYNC_INTERVAL=1h +MEMBY_SYNC_ON_START=false + +# Optional Emby service account for imports. Without it the gateway borrows the most +# recently active TV session, which works but stops if that user is removed. +#MEMBY_SYNC_USER_ID= +#MEMBY_SYNC_API_KEY= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..78b356f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +# Normalise to LF in the repository. Without this, committing from Windows stores CRLF +# and `gradlew` and the Go sources break when checked out on Linux or built in Docker. +* text=auto eol=lf + +# Windows-only scripts keep CRLF in the working tree. +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# Must stay LF: executed by a shell. +gradlew text eol=lf +*.sh text eol=lf + +*.png binary +*.jar binary +*.apk binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab88d40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Android / Gradle +*.iml +.gradle/ +.kotlin/ +build/ +/captures +.externalNativeBuild +.cxx + +# Machine-specific: points at this SDK install, never shared. +local.properties + +# IDE +/.idea/ +.DS_Store + +# Gateway +# Holds the Postgres password, admin token and Emby address. +.env +/server/bin/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..86f908a --- /dev/null +++ b/CLAUDE.md @@ -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::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//` rather than growing +them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`); +everything must be reachable by D-pad only. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1e129b9 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# Memby (Android TV) + +An independent Android TV client for Emby, by **ponzischeme89**. Sign in to browse your +personalised home screen, resume titles, play recent movies, and choose audio or subtitle +tracks during playback. The screensaver (Dream) code remains isolated from the client work. + +## Features + +- **System screensaver** via `DreamService` — auto-starts on idle once selected in the + TV's Screensaver settings. Also previewable from the app's home screen. +- Random, cross-fading **backdrops** of Movies and Series that have backdrop images. +- **Interactive**: OK reveals Play / Favorite actions; ◄ ► change the backdrop; Back exits. +- **In-app playback** with Media3/ExoPlayer (movies play directly; a series plays its + next-up / first episode). +- **Favorites** managed through the Emby API and shown on the home screen. + +## Tech stack + +- Kotlin + Jetpack **Compose for TV** (`androidx.tv:tv-material`) +- **Media3 / ExoPlayer** for playback +- Retrofit + OkHttp + kotlinx.serialization for the Emby REST API +- DataStore for persisted connection/session +- Coil for backdrop image loading + +## Project layout + +``` +app/src/main/java/com/ponzischeme89/memby/ + MembyApp.kt Application; initialises the ServiceLocator + ServiceLocator.kt Manual DI (settings + repository) + data/ + SettingsStore.kt DataStore-backed connection/session state + ServerConfig.kt Which backend this build talks to + EmbyRepository.kt Content, favorites, playback; dual gateway/direct paths + analytics/RowAnalytics.kt Row engagement buffering + model/ Emby + gateway DTOs (kotlinx.serialization) + remote/EmbyApi.kt Retrofit interface for Emby + remote/GatewayApi.kt Retrofit interface for the Memby gateway + ui/ + MainActivity.kt Setup, profiles, home + HomeViewModel.kt Home state, row analytics, refresh + HomeComponents.kt Navigation rail, rows, cards + MaintenanceScreen.kt Full-screen offline state + settings/SettingsSheet.kt Settings panel + screensaver/ Shared slideshow + in-app preview host + player/PlayerActivity.kt Media3 playback + screensaver/MembyDreamService.kt System screensaver (hosts ScreensaverContent) + +server/ The Memby gateway (Go) — see server/README.md +``` + +## Build & install + +You need **JDK 17** and the **Android SDK** (Android Studio bundles both). + +Open the folder in Android Studio (Giraffe/Koala or newer) and let it sync, **or** from a +terminal: + +```powershell +# Android Studio writes local.properties automatically. If building from the CLI, +# point it at your SDK first: +"sdk.dir=C:\\Users\\\\AppData\\Local\\Android\\Sdk" | Out-File -Encoding ascii local.properties + +.\gradlew.bat assembleDebug # build the APK +.\gradlew.bat installDebug # install to a connected Android TV / emulator +# Preferred for a TV already showing the Dream: clears Memby, installs, then reopens +# it so the old render surface cannot remain black. Pass -Serial when more than one +# device is attached. +.\deploy-debug.ps1 -Serial 192.168.20.3:41479 +``` + +The APK lands in `app/build/outputs/apk/debug/app-debug.apk`. + +## Two ways to run + +The client can talk to Emby directly, or through the **Memby gateway** — a Go service in +`server/` that runs in Docker alongside Postgres and Redis and owns auth, caching, search +and the shaping of TV screens. With a gateway the launcher is one request instead of four, +and the TV holds a revocable gateway token rather than a live Emby token. + +``` +direct: TV ──────────────────────────────► Emby +gateway: TV ──► Memby gateway ──► Emby (metadata + artwork) + TV ─────────────────────► Emby (video, always direct-play) +``` + +Which one a build uses is decided by `memby.gatewayUrl` in `gradle.properties`: set it and +the app is a thin client; leave it blank and nothing changes from the direct path below. +See [`server/README.md`](server/README.md) to run the container. + +The gateway also imports Emby's catalogue into Postgres (once manually, then hourly for +new episodes), composes the home rows — including "Recommended from your watching +history" — and has an admin page at `/admin/` for imports, an offline switch, and +per-row engagement. + +## Server address + +Memby is built for one Emby server, so the address is baked into the APK instead of being +typed on a TV remote. Set it in `gradle.properties`: + +```properties +memby.serverUrl=http://192.168.1.10:8096 +``` + +It can also come from `~/.gradle/gradle.properties` (keeps it out of the repo) or a single +build: `.\gradlew.bat assembleDebug -Pmemby.serverUrl=http://192.168.1.10:8096`. + +The value becomes `BuildConfig.EMBY_SERVER_URL`, read through +`data/ServerConfig.kt`. When it is set, the setup screen only asks for a username and +password, and the address wins over whatever a saved session recorded — so moving the +server is a property change plus a reinstall, with no user action. Leaving the property +**blank** restores the original behaviour: users type the address themselves. + +## First run + +1. Launch **Memby** from the Android TV launcher. +2. Sign in with your Emby username and password. (If the build has no hardwired server, + enter its address first, e.g. `http://192.168.1.10:8096`.) +3. **Preview screensaver** to test it, or **Set as system screensaver** to open the TV's + screensaver settings and choose "Memby Screensaver". + +## Upgrading to v0.1.53 + +The package and install identity both became `com.ponzischeme89.memby` in this release +(previously `com.mattcohen.embyclientsname`). Android treats a new `applicationId` as a +different app, so on every TV: + +1. Install v0.1.53 — it appears as a **second** Memby entry in the launcher. +2. Sign in again; the previous session does not carry over. +3. Uninstall the old app: `adb uninstall com.mattcohen.embyclientsname`. +4. Re-select Memby in the TV's Screensaver settings — the Dream's component name changed + as well, so the old selection no longer resolves. + +## Notes & limitations + +- Playback uses Emby's direct stream (`/Videos/{id}/stream?static=true`). This direct-plays + containers/codecs ExoPlayer supports (most MP4/H.264, many MKV). Server-side transcoding + is not requested; unusual codecs may need it — a future enhancement is to call Emby's + `PlaybackInfo` endpoint and use the returned HLS transcode URL (the `media3-exoplayer-hls` + dependency is already included). +- Cleartext HTTP is enabled so local `http://` servers work out of the box. For an HTTPS-only + server this is unnecessary but harmless. +- The device is remembered across sign-outs (stable `DeviceId`); credentials are cleared. +``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f7f0072 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,118 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +// Set in gradle.properties (or ~/.gradle/gradle.properties, or -Pmemby.serverUrl=...). +// Blank means "no hardwired server": the setup screen asks the user for an address. +val embyServerUrl: String = (project.findProperty("memby.serverUrl") as String?).orEmpty().trim() + +// The Memby gateway container. When set, the client talks to it instead of Emby and +// becomes a thin renderer; blank keeps the direct-to-Emby path above. +val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as String?).orEmpty().trim() + +android { + namespace = "com.ponzischeme89.memby" + compileSdk = 35 + + defaultConfig { + // Matches the Kotlin package. Changed from com.mattcohen.embyclientsname at + // v0.1.53: a new applicationId installs as a separate app, so that release + // required uninstalling the old one and signing in again. + applicationId = "com.ponzischeme89.memby" + minSdk = 23 + targetSdk = 35 + // versionCode is derived from versionName: major*10000 + minor*100 + patch. + // 0.1.53 -> 153. Keep them in step; the in-app updater compares versionName, + // but Android will not install an APK whose versionCode went backwards. + versionCode = 153 + versionName = "0.1.53" + + buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"") + buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + buildConfig = true + } + + lint { + // media3 marks some APIs (e.g. PlayerView) with a Lint-based @UnstableApi + // opt-in check; don't let it fail the build. + abortOnError = false + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + + // Core / lifecycle / activity + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.savedstate:savedstate-ktx:1.2.1") + // Measurement only: JankStats is enabled by PerformanceMonitor for debug builds. + implementation("androidx.metrics:metrics-performance:1.0.0") + + // Compose (versions from BOM) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.foundation:foundation") + implementation("androidx.compose.material:material-icons-extended") + + // Compose for TV + implementation("androidx.tv:tv-material:1.0.0") + + // Image loading + implementation("io.coil-kt:coil-compose:2.7.0") + + // Networking + JSON + implementation("com.squareup.retrofit2:retrofit:2.11.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("com.squareup.okhttp3:logging-interceptor:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0") + + // DataStore (persisted settings) + implementation("androidx.datastore:datastore-preferences:1.1.1") + + // Media3 / ExoPlayer for in-app playback + implementation("androidx.media3:media3-exoplayer:1.5.1") + implementation("androidx.media3:media3-exoplayer-hls:1.5.1") + implementation("androidx.media3:media3-ui:1.5.1") + + debugImplementation("androidx.compose.ui:ui-tooling") + + testImplementation("junit:junit:4.13.2") +} diff --git a/app/logo.png b/app/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ca84b69649d0a5f81c73ec56a43a5c6cc7c56f0d GIT binary patch literal 869641 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

As`|3Y@=q6_-jYy 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt b/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt new file mode 100644 index 0000000..be58139 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/MembyApp.kt @@ -0,0 +1,34 @@ +package com.ponzischeme89.memby + +import android.app.Application +import coil.Coil +import coil.ImageLoader +import coil.disk.DiskCache +import coil.memory.MemoryCache + +class MembyApp : Application() { + override fun onCreate() { + super.onCreate() + Coil.setImageLoader( + ImageLoader.Builder(this) + .memoryCache { + MemoryCache.Builder(this) + .maxSizePercent(0.08) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(cacheDir.resolve("media_artwork")) + .maxSizeBytes(128L * 1024L * 1024L) + .build() + } + // Emby artwork URLs include an image tag, so changed artwork gets a new + // cache key. Keep tagged thumbnails available even when a server sends + // conservative cache headers. + .respectCacheHeaders(false) + .crossfade(false) + .build(), + ) + ServiceLocator.init(this) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt new file mode 100644 index 0000000..9a514e7 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ServiceLocator.kt @@ -0,0 +1,23 @@ +package com.ponzischeme89.memby + +import android.content.Context +import com.ponzischeme89.memby.data.EmbyRepository +import com.ponzischeme89.memby.data.SettingsStore + +/** + * Tiny manual dependency container. Initialised once from [MembyApp] so that the + * DreamService, activities and composables can all share a single repository / + * settings instance without pulling in a DI framework. + */ +object ServiceLocator { + lateinit var settings: SettingsStore + private set + lateinit var repository: EmbyRepository + private set + + fun init(context: Context) { + if (::repository.isInitialized) return + settings = SettingsStore(context.applicationContext) + repository = EmbyRepository(settings) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt new file mode 100644 index 0000000..1cce3e8 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -0,0 +1,678 @@ +package com.ponzischeme89.memby.data + +import com.ponzischeme89.memby.data.model.AuthRequest +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.GatewayFlagRequest +import com.ponzischeme89.memby.data.model.GatewayLoginRequest +import com.ponzischeme89.memby.data.model.GatewayPlaybackReport +import com.ponzischeme89.memby.data.model.GatewayRowEvent +import com.ponzischeme89.memby.data.model.GatewayRowEvents +import com.ponzischeme89.memby.data.model.HomeRow +import com.ponzischeme89.memby.data.model.PlaybackReport +import com.ponzischeme89.memby.data.remote.EmbyApi +import com.ponzischeme89.memby.data.remote.EmbyServiceFactory +import com.ponzischeme89.memby.data.remote.GatewayApi +import com.ponzischeme89.memby.data.remote.GatewayServiceFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import retrofit2.HttpException +import java.io.IOException +import java.net.URLEncoder + +/** + * One batch home response. [partial] means at least one row failed upstream and the rest + * is still worth rendering. + */ +data class HomeSnapshot( + /** Rows exactly as the server composed them, in display order. */ + val rows: List = emptyList(), + val continueWatching: List = emptyList(), + val nextUp: List = emptyList(), + val favorites: List = emptyList(), + val latestMovies: List = emptyList(), + val partial: Boolean = false, +) + +/** A resolved, directly playable stream. */ +data class Playable( + val itemId: String, + val title: String, + val url: String, + val resumePositionMs: Long = 0L, +) + +class EmbyRepository(private val settings: SettingsStore) { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Volatile + private var snapshot: Settings = Settings.EMPTY + + init { + scope.launch { settings.settingsFlow.collect { snapshot = it } } + } + + val settingsFlow: Flow get() = settings.settingsFlow + private val _playbackStops = MutableSharedFlow(extraBufferCapacity = 1) + val playbackStops = _playbackStops.asSharedFlow() + + fun cachedHome(): HomeCache? = settings.homeCache(snapshot) + + suspend fun cacheHome(content: HomeCache) = settings.setHomeCache(content) + + // --- API instance caching (rebuilt only when the server URL changes) ----- + + private var cachedApi: EmbyApi? = null + private var cachedBaseUrl: String? = null + + /** + * The Memby gateway, when this build has one. Its address is fixed at build time, so + * unlike the Emby client this never needs rebuilding. + */ + private val gatewayApi: GatewayApi? by lazy { + ServerConfig.gatewayUrl?.let { url -> + GatewayServiceFactory.create(url) { snapshot.token } + } + } + + private fun requireGateway(): GatewayApi = gatewayApi ?: error("No Memby gateway configured") + + /** True when the backend can return the whole home screen in one request. */ + val supportsBatchHome: Boolean get() = ServerConfig.isGateway + + private fun apiFor(serverUrl: String): EmbyApi { + val base = normalizeServerUrl(serverUrl) + cachedApi?.let { if (cachedBaseUrl == base) return it } + val api = EmbyServiceFactory.create( + baseUrl = base, + deviceIdProvider = { snapshot.deviceId.ifEmpty { "memby" } }, + tokenProvider = { snapshot.token }, + ) + cachedApi = api + cachedBaseUrl = base + return api + } + + /** + * The server all requests and media URLs point at. A build that hardwires an address + * (see [ServerConfig]) always wins, so repointing every install is a property change + * plus a reinstall — no user action, and no stale address left in a saved session. + */ + private val activeServerUrl: String? get() = ServerConfig.hardwiredUrl ?: snapshot.serverUrl + + private fun requireApi(): EmbyApi { + val url = activeServerUrl ?: error("Not connected to a server") + return apiFor(url) + } + + // --- Authentication ------------------------------------------------------ + + /** + * Signs in. [serverUrl] is only consulted when the build does not hardwire one — + * with a hardwired address the setup screen never collects it. + */ + suspend fun authenticate(serverUrl: String, username: String, password: String) { + settings.ensureDeviceId() + snapshot = settings.snapshot() // pick up the freshly-generated device id + + ServerConfig.gatewayUrl?.let { gateway -> + // The gateway holds the Emby token; this device only ever stores the gateway + // token, so the same `token` slot in DataStore serves both modes. + val result = requireGateway().login( + GatewayLoginRequest( + username = username, + password = password, + deviceId = snapshot.deviceId.ifEmpty { "memby" }, + ), + ) + require(result.token.isNotBlank() && result.userId.isNotBlank()) { + "Gateway did not return a session" + } + settings.saveSession( + gateway, + result.token, + result.userId, + result.username.ifBlank { username }, + result.serverId.takeIf { it.isNotBlank() }, + ) + snapshot = settings.snapshot() + return + } + + val base = resolveServerUrl(ServerConfig.hardwiredUrl, serverUrl) + ?: error("No Emby server address configured") + val api = apiFor(base) + val result = api.authenticate(AuthRequest(username = username, pw = password)) + val token = result.accessToken + val userId = result.user?.id + require(!token.isNullOrBlank() && !userId.isNullOrBlank()) { + "Server did not return an access token" + } + settings.saveSession(base, token, userId, username, result.serverId) + snapshot = settings.snapshot() + } + + suspend fun signOut() { + // Retire the gateway token server-side too, so a lost TV can't keep reading the + // library. A failure here must not block the local sign-out. + if (ServerConfig.isGateway && !snapshot.token.isNullOrBlank()) { + runCatching { requireGateway().logout() } + } + settings.clearSession() + snapshot = settings.snapshot() + cachedApi = null + cachedBaseUrl = null + } + + suspend fun switchProfile(profile: EmbyProfile) { + settings.switchProfile(profile) + snapshot = settings.snapshot() + cachedApi = null + cachedBaseUrl = null + } + + // --- Content ------------------------------------------------------------- + + /** + * The whole home screen in one request. Only available against a gateway — check + * [supportsBatchHome] first. + */ + suspend fun getHome(limit: Int = 24): HomeSnapshot { + val home = requireGateway().home(limit) + return HomeSnapshot( + rows = home.rows, + continueWatching = home.continueWatching, + nextUp = home.nextUp, + favorites = home.favorites, + latestMovies = home.latestMovies, + partial = home.partial, + ) + } + + /** A shuffled set of movies & shows that actually have a backdrop image. */ + suspend fun getScreensaverItems(limit: Int = 200): List { + if (ServerConfig.isGateway) { + return requireGateway().screensaver(limit).items.filter { hasBackdrop(it) } + } + val userId = snapshot.userId ?: error("Not connected") + val result = requireApi().getItems( + userId, + mapOf( + "IncludeItemTypes" to "Movie,Series", + "Recursive" to "true", + "SortBy" to "Random", + "Limit" to limit.toString(), + "Fields" to "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks", + "ImageTypeLimit" to "1", + "EnableImageTypes" to "Backdrop,Logo", + "EnableUserData" to "true", + ), + ) + return result.items.filter { hasBackdrop(it) } + } + + /** + * A deliberately tiny, movie-only request used during a cold start. It gives the + * UI a genuine Emby backdrop while the larger mixed library queue is still loading. + */ + suspend fun getStartupBackdropMovie(): BaseItem? { + if (ServerConfig.isGateway) { + // The gateway keeps a warm, cached backdrop pool, so the "tiny first query" + // trick the direct path needs is unnecessary here. + return requireGateway().screensaver(limit = 1).items.firstOrNull { hasBackdrop(it) } + } + val userId = snapshot.userId ?: error("Not connected") + return requireApi().getItems( + userId, + mapOf( + "IncludeItemTypes" to "Movie", + "Recursive" to "true", + "Filters" to "HasBackdrop", + "SortBy" to "Random", + "Limit" to "1", + "Fields" to "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,RunTimeTicks", + "ImageTypeLimit" to "1", + "EnableImageTypes" to "Backdrop,Logo", + "EnableUserData" to "true", + ), + ).items.firstOrNull { hasBackdrop(it) } + } + + // Against a gateway the per-row getters just slice the batch response, which Redis + // has already answered once for this user. They exist so the direct-to-Emby path and + // any caller that wants a single row keep working unchanged. + + /** First home-page slice of favorited movies & shows. */ + suspend fun getFavorites(limit: Int = 24): List { + if (ServerConfig.isGateway) return getHome(limit).favorites + return getHomeItems( + params = mapOf( + "Filters" to "IsFavorite", + "IncludeItemTypes" to "Movie,Series", + "Recursive" to "true", + "SortBy" to "SortName", + "SortOrder" to "Ascending", + "Limit" to limit.toString(), + ), + fields = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio", + imageTypes = "Backdrop,Primary,Logo", + includeUserData = true, + ) + } + + /** Unfinished movies and episodes for the current Emby user. */ + suspend fun getContinueWatching(limit: Int = 24): List { + if (ServerConfig.isGateway) return getHome(limit).continueWatching + return getHomeItems( + params = mapOf( + "Filters" to "IsResumable", + "IncludeItemTypes" to "Movie,Episode", + "Recursive" to "true", + "SortBy" to "DatePlayed", + "SortOrder" to "Descending", + "Limit" to limit.toString(), + ), + fields = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio", + imageTypes = "Backdrop,Primary,Logo", + includeUserData = true, + ) + } + + /** Episodes the server recommends playing next, excluding resumable duplicates in the UI. */ + suspend fun getNextUp(limit: Int = 24): List { + if (ServerConfig.isGateway) return getHome(limit).nextUp + val userId = snapshot.userId ?: error("Not connected") + return requireApi().getNextUp( + mapOf( + "UserId" to userId, + "Limit" to limit.toString(), + "Fields" to "Overview,ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio", + "ImageTypeLimit" to "1", + "EnableImageTypes" to "Backdrop,Primary,Logo", + "EnableTotalRecordCount" to "false", + "EnableUserData" to "true", + ), + ).items + } + + /** Recently added films, used as a compact discovery row on the client home. */ + suspend fun getLatestMovies(limit: Int = 24): List { + if (ServerConfig.isGateway) return getHome(limit).latestMovies + return getHomeItems( + params = mapOf( + "IncludeItemTypes" to "Movie", + "Recursive" to "true", + "SortBy" to "DateCreated", + "SortOrder" to "Descending", + "Limit" to limit.toString(), + ), + fields = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio", + imageTypes = "Backdrop,Primary,Logo", + includeUserData = true, + ) + } + + /** Library-wide search. Gateway-only: the direct path has no search UI behind it. */ + suspend fun search(term: String, limit: Int = 40): List = + requireGateway().search(term, limit).items + + /** + * Recommendation rows on their own, forcing the gateway to build them synchronously + * if its cache is cold. [getHome] already carries them once warm, so this is only + * needed to pull them in without a full home refresh. + */ + suspend fun getRecommendations(): List = requireGateway().recommendations().rows + + /** Full item metadata, requested only after focus settles on an item. */ + suspend fun getItemDetails(itemId: String): BaseItem { + if (ServerConfig.isGateway) return requireGateway().item(itemId) + val userId = snapshot.userId ?: error("Not connected") + return requireApi().getItem( + userId = userId, + itemId = itemId, + fields = "Overview,Genres,MediaStreams,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio", + ) + } + + /** Tight list endpoint shape: detail-only fields are never fetched on home. */ + private suspend fun getHomeItems( + params: Map, + fields: String, + imageTypes: String, + includeUserData: Boolean = false, + ): List { + val userId = snapshot.userId ?: error("Not connected") + return requireApi().getItems(userId, params + mapOf( + "Fields" to fields, + "ImageTypeLimit" to "1", + "EnableImages" to "true", + "EnableImageTypes" to imageTypes, + "EnableTotalRecordCount" to "false", + "EnableUserData" to includeUserData.toString(), + )).items + } + + /** Toggles favorite state, returning the new value. */ + suspend fun toggleFavorite(item: BaseItem): Boolean = + setFavorite(item.id, !item.isFavorite) + + /** + * Sets favorite state to an explicit [favorite] value and returns the server's + * resulting state. Safer than [toggleFavorite] for optimistic UI, which may + * flip faster than the source item's cached [BaseItem.isFavorite] updates. + */ + suspend fun setFavorite(itemId: String, favorite: Boolean): Boolean { + if (ServerConfig.isGateway) { + return requireGateway().setFavorite(itemId, GatewayFlagRequest(favorite)).isFavorite + } + val userId = snapshot.userId ?: error("Not connected") + val api = requireApi() + val result = if (favorite) { + api.addFavorite(userId, itemId) + } else { + api.removeFavorite(userId, itemId) + } + return result.isFavorite + } + + /** Sets watched state explicitly and returns the value confirmed by Emby. */ + suspend fun setPlayed(itemId: String, played: Boolean): Boolean { + if (ServerConfig.isGateway) { + return requireGateway().setPlayed(itemId, GatewayFlagRequest(played)).played + } + val userId = snapshot.userId ?: error("Not connected") + val api = requireApi() + val result = if (played) { + api.markPlayed(userId, itemId) + } else { + api.markUnplayed(userId, itemId) + } + return result.played + } + + /** Returns Emby's first local trailer for an item, when one is available. */ + suspend fun getLocalTrailer(itemId: String): BaseItem? { + if (ServerConfig.isGateway) { + // The gateway answers 404 when an item has no trailer, which is a normal + // outcome here rather than an error worth surfacing. + return runCatching { requireGateway().trailer(itemId) } + .getOrElse { if (it is HttpException && it.code() == 404) null else throw it } + } + val userId = snapshot.userId ?: error("Not connected") + return requireApi().getLocalTrailers(userId, itemId).items.firstOrNull() + } + + /** + * Uploads a batch of row-engagement events. Silent on the direct path (nothing is + * listening) and silent on failure — telemetry must never surface on a TV. + */ + fun reportRowEvents(events: List) { + if (!ServerConfig.isGateway || events.isEmpty() || snapshot.token.isNullOrBlank()) return + scope.launch { + runCatching { requireGateway().reportRowEvents(GatewayRowEvents(events)) } + } + } + + /** Backdrop rotation interval, clamped to a sane range. */ + fun rotationIntervalMillis(): Long = + snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L + + /** Whether a usable session is currently persisted. */ + fun isSignedIn(): Boolean = snapshot.isSignedIn + + /** + * Resolves an item to something ExoPlayer can stream. Movies play directly; + * for a series we play the next-up episode (falling back to the first one). + */ + suspend fun resolvePlayable(item: BaseItem): Playable { + if (ServerConfig.isGateway) { + // Episode selection for a series is the gateway's job now. + val playback = requireGateway().playback(item.id) + return Playable( + itemId = playback.itemId, + title = playback.title.ifBlank { item.name }, + url = playback.url, + resumePositionMs = playback.resumePositionMs, + ) + } + if (item.isSeries) { + val userId = snapshot.userId ?: error("Not connected") + val episode = firstNextUpEpisode(userId, item.id) ?: firstEpisode(userId, item.id) + requireNotNull(episode) { "No episodes found for ${item.name}" } + val title = buildString { + append(item.name) + episode.name.takeIf { it.isNotBlank() }?.let { append(" – $it") } + } + return Playable(episode.id, title, buildStreamUrl(episode.id), episode.resumePositionMs) + } + return Playable(item.id, item.name, buildStreamUrl(item.id), item.resumePositionMs) + } + + suspend fun reportPlaybackStarted(itemId: String, positionMs: Long) { + if (ServerConfig.isGateway) { + requireGateway().report("started", GatewayPlaybackReport(itemId, positionMs)) + return + } + requireApi().reportPlaybackStarted(playbackReport(itemId, positionMs, isPaused = false)) + } + + suspend fun reportPlaybackProgress(itemId: String, positionMs: Long, isPaused: Boolean) { + if (ServerConfig.isGateway) { + requireGateway().report("progress", GatewayPlaybackReport(itemId, positionMs, isPaused)) + return + } + requireApi().reportPlaybackProgress(playbackReport(itemId, positionMs, isPaused)) + } + + suspend fun reportPlaybackStopped(itemId: String, positionMs: Long) { + try { + if (ServerConfig.isGateway) { + // Stopping is also what drops the gateway's cached rows for this user, + // so Continue Watching reflects the new position on the next home load. + requireGateway().report("stopped", GatewayPlaybackReport(itemId, positionMs, isPaused = true)) + } else { + requireApi().reportPlaybackStopped(playbackReport(itemId, positionMs, isPaused = true)) + } + } finally { + _playbackStops.tryEmit(itemId) + } + } + + fun enqueuePlaybackStopped(itemId: String, positionMs: Long) { + scope.launch { + runCatching { reportPlaybackStopped(itemId, positionMs) } + } + } + + private suspend fun firstNextUpEpisode(userId: String, seriesId: String): BaseItem? = + runCatching { + requireApi().getNextUp( + mapOf( + "UserId" to userId, + "SeriesId" to seriesId, + "Limit" to "1", + "Fields" to "RunTimeTicks", + "EnableUserData" to "true", + ), + ).items.firstOrNull() + }.getOrNull() + + private suspend fun firstEpisode(userId: String, seriesId: String): BaseItem? = + runCatching { + requireApi().getEpisodes( + seriesId, + mapOf( + "UserId" to userId, + "Limit" to "1", + "Fields" to "RunTimeTicks", + "EnableUserData" to "true", + ), + ).items.firstOrNull() + }.getOrNull() + + // --- URL helpers --------------------------------------------------------- + + /** Backdrop image URL for an item, or null if it has none. */ + fun backdropUrl(item: BaseItem, maxWidth: Int = 1920): String? { + val (id, tag) = when { + item.backdropImageTags.isNotEmpty() -> item.id to item.backdropImageTags.first() + item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty() -> + item.parentBackdropItemId to item.parentBackdropImageTags.first() + else -> return null + } + return imageUrl(id, "Backdrop", tag, maxWidth, directIndex = true) + } + + /** + * The item's "Logo" image (the stylised title treatment) from Emby metadata, + * or null when the item has no logo. Used to show artwork in place of the + * plain-text title in the screensaver. + */ + fun logoUrl(item: BaseItem, maxWidth: Int = 800): String? { + val (id, tag) = when { + item.imageTags["Logo"] != null -> item.id to item.imageTags.getValue("Logo") + item.parentLogoItemId != null && item.parentLogoImageTag != null -> + item.parentLogoItemId to item.parentLogoImageTag + else -> return null + } + return imageUrl(id, "Logo", tag, maxWidth) + } + + /** Primary (poster) image URL, used as a card fallback. */ + fun primaryUrl(item: BaseItem, maxWidth: Int = 500): String? { + val tag = item.imageTags["Primary"] ?: return null + return imageUrl(item.id, "Primary", tag, maxWidth) + } + + /** + * Builds an artwork URL for whichever backend this build uses. + * + * Coil fetches these as plain URLs with no interceptor attached, so the credential + * has to travel in the query string either way: `api_key` for Emby, `t` for the + * gateway. The gateway form is preferable — that token is revocable and grants + * nothing but Memby's own API. + */ + private fun imageUrl( + itemId: String, + imageType: String, + tag: String, + maxWidth: Int, + directIndex: Boolean = false, + ): String? { + val token = snapshot.token + ServerConfig.gatewayUrl?.let { gateway -> + if (token.isNullOrBlank()) return null + return buildString { + append(gateway.trimEnd('/')) + append("/v1/images/").append(itemId).append('/').append(imageType.lowercase()) + append("?maxWidth=").append(maxWidth) + append("&quality=90") + append("&tag=").append(encode(tag)) + append("&t=").append(encode(token)) + } + } + + val base = activeServerUrl ?: return null + return buildString { + append(base.trimEnd('/')) + append("/Items/").append(itemId).append("/Images/").append(imageType) + if (directIndex) append("/0") + append("?maxWidth=").append(maxWidth) + append("&quality=90") + append("&tag=").append(encode(tag)) + token?.let { append("&api_key=").append(encode(it)) } + } + } + + fun hasBackdrop(item: BaseItem): Boolean = + item.backdropImageTags.isNotEmpty() || + (item.parentBackdropItemId != null && item.parentBackdropImageTags.isNotEmpty()) + + private fun buildStreamUrl(itemId: String): String { + val base = activeServerUrl?.trimEnd('/') ?: error("Not connected") + val token = snapshot.token.orEmpty() + val deviceId = snapshot.deviceId.ifEmpty { "memby" } + return "$base/Videos/$itemId/stream" + + "?static=true" + + "&api_key=${encode(token)}" + + "&DeviceId=${encode(deviceId)}" + } + + private fun playbackReport(itemId: String, positionMs: Long, isPaused: Boolean) = + PlaybackReport( + itemId = itemId, + positionTicks = millisecondsToTicks(positionMs), + isPaused = isPaused, + ) + + private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8") +} + +internal fun millisecondsToTicks(milliseconds: Long): Long = + milliseconds.coerceAtLeast(0L) * 10_000L + +private val BaseItem.resumePositionMs: Long + get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L) + +/** + * Maps an exception to a short, TV-readable message. Never surfaces raw HTTP + * bodies or stack traces (which could contain tokens) to the screen. + */ +fun friendlyEmbyError(t: Throwable): String = when (t) { + is IOException -> "Can't reach the Emby server. Check your network." + is HttpException -> when (t.code()) { + 401 -> "Session expired. Open Memby to sign in again." + 403 -> "Access denied by the server." + 404 -> "Not found on the server." + // The gateway answers 503 when an operator has deliberately taken Memby down. + // Showing their message beats a generic "server problem" the viewer can do + // nothing about. + 503 -> maintenanceMessage(t) ?: "Memby is unavailable right now. Try again shortly." + in 500..599 -> "The Emby server had a problem. Try again." + else -> "Server error (${t.code()})." + } + else -> "Something went wrong. Try again." +} + +private fun maintenanceMessage(t: HttpException): String? = runCatching { + parseMaintenanceMessage(t.response()?.errorBody()?.string().orEmpty()) +}.getOrNull() + +/** + * Reads the operator's message out of a maintenance response, or null when this 503 is + * something else. Never surfaces a raw body: only the known `message` field is trusted, + * and only up to a length that fits on a TV. + */ +internal fun parseMaintenanceMessage(body: String): String? { + if (body.isBlank()) return null + return runCatching { + val parsed = Json { ignoreUnknownKeys = true } + .decodeFromString(body) + parsed.message?.trim()?.takeIf { parsed.maintenance && it.isNotBlank() }?.take(160) + }.getOrNull() +} + +/** True when this failure is the gateway reporting a deliberate outage. */ +fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() == 503 + +@Serializable +private data class MaintenanceResponse( + val maintenance: Boolean = false, + val message: String? = null, +) + +/** Ensures a scheme and strips a trailing slash for consistent base handling. */ +fun normalizeServerUrl(raw: String): String { + var url = raw.trim() + if (!url.startsWith("http://", true) && !url.startsWith("https://", true)) { + url = "http://$url" + } + return url.trimEnd('/') +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/ServerConfig.kt b/app/src/main/java/com/ponzischeme89/memby/data/ServerConfig.kt new file mode 100644 index 0000000..eb2ec53 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/ServerConfig.kt @@ -0,0 +1,52 @@ +package com.ponzischeme89.memby.data + +import com.ponzischeme89.memby.BuildConfig + +/** + * Where this build gets its data. + * + * Two addresses are baked in at build time (see gradle.properties): + * + * - `memby.gatewayUrl` — the Memby gateway container. When set, the client is a thin + * renderer: the gateway owns auth, caching, search and screen shaping. + * - `memby.serverUrl` — the Emby server, used directly when no gateway is configured. + * Keeping this path alive means a gateway outage is a config change away from being + * routed around, and it is what the app falls back to during the migration. + * + * Neither is typed on a TV remote; both are properties of the build. + */ +object ServerConfig { + + /** The Memby gateway, normalised, or null when this build talks to Emby directly. */ + val gatewayUrl: String? = + BuildConfig.MEMBY_GATEWAY_URL.trim() + .takeIf { it.isNotBlank() } + ?.let(::normalizeServerUrl) + + /** The hardwired Emby server, normalised, or null when this build doesn't pin one. */ + val hardwiredUrl: String? = + BuildConfig.EMBY_SERVER_URL.trim() + .takeIf { it.isNotBlank() } + ?.let(::normalizeServerUrl) + + val isGateway: Boolean get() = gatewayUrl != null + + val isHardwired: Boolean get() = hardwiredUrl != null + + /** The address this build actually signs in against. */ + val backendUrl: String? get() = gatewayUrl ?: hardwiredUrl + + /** Host (and port) of that address, for display on the setup screen. */ + val displayHost: String? + get() = backendUrl?.substringAfter("://")?.trimEnd('/') +} + +/** + * Resolves the server to use: the hardwired address when this build pins one, otherwise + * whatever the user typed. Returns null when neither is available. + */ +fun resolveServerUrl(hardwired: String?, entered: String): String? = when { + !hardwired.isNullOrBlank() -> normalizeServerUrl(hardwired) + entered.isNotBlank() -> normalizeServerUrl(entered) + else -> null +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt new file mode 100644 index 0000000..5d53de7 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -0,0 +1,308 @@ +package com.ponzischeme89.memby.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.UUID + +private val Context.dataStore: DataStore by preferencesDataStore(name = "emby_settings") + +/** Persisted connection state. */ +data class Settings( + val serverUrl: String? = null, + val token: String? = null, + val userId: String? = null, + val serverId: String? = null, + val username: String? = null, + val deviceId: String = "", + val rotationIntervalSeconds: Int = DEFAULT_ROTATION_SECONDS, + // Where "Check for updates" looks: a Gitea host + "owner/repo", plus an access + // token for the (private) repo's release API and asset downloads. + val updateBaseUrl: String? = null, + val updateRepo: String? = null, + val updateToken: String? = null, + // Show each item's Emby "Logo" image in place of the plain-text title. + val showTitleLogo: Boolean = true, + // Foreground colour of the slide-progress ring, as an RRGGBB hex string. + val ringColorHex: String = DEFAULT_RING_COLOR, + val lastBackdropUrl: String? = null, + /** Comma-separated, user-controlled order of rows shown on the client home. */ + val homeSections: String = DEFAULT_HOME_SECTIONS, + val homeCacheJson: String? = null, + val homeCardDensity: String = DEFAULT_HOME_CARD_DENSITY, + val showHomeCardMetadata: Boolean = true, + val profiles: List = emptyList(), +) { + val isSignedIn: Boolean + get() = !serverUrl.isNullOrBlank() && !token.isNullOrBlank() && !userId.isNullOrBlank() + + val activeProfileId: String? + get() = profiles.firstOrNull { + it.userId == userId && it.serverUrl == serverUrl + }?.id + + companion object { + const val DEFAULT_ROTATION_SECONDS = 15 + const val DEFAULT_RING_COLOR = "FFFFFF" + const val DEFAULT_HOME_SECTIONS = "continue,favorites,latest" + const val DEFAULT_HOME_CARD_DENSITY = "standard" + val EMPTY = Settings() + } +} + +@Serializable +data class EmbyProfile( + val id: String, + val serverUrl: String, + val token: String, + val userId: String, + val username: String, + val serverId: String? = null, + val homeCacheJson: String? = null, +) + +class SettingsStore(private val context: Context) { + + private object Keys { + val SERVER_URL = stringPreferencesKey("server_url") + val TOKEN = stringPreferencesKey("token") + val USER_ID = stringPreferencesKey("user_id") + val SERVER_ID = stringPreferencesKey("server_id") + val USERNAME = stringPreferencesKey("username") + val DEVICE_ID = stringPreferencesKey("device_id") + val ROTATION_SECONDS = intPreferencesKey("rotation_interval_seconds") + val UPDATE_BASE_URL = stringPreferencesKey("update_base_url") + val UPDATE_REPO = stringPreferencesKey("update_repo") + val UPDATE_TOKEN = stringPreferencesKey("update_token") + val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo") + val RING_COLOR = stringPreferencesKey("ring_color") + val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url") + val HOME_SECTIONS = stringPreferencesKey("home_sections") + val HOME_CACHE = stringPreferencesKey("home_cache") + val HOME_CARD_DENSITY = stringPreferencesKey("home_card_density") + val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata") + val PROFILES = stringPreferencesKey("profiles") + } + + val settingsFlow: Flow = context.dataStore.data.map { p -> + val storedProfiles = decodeProfiles(p[Keys.PROFILES]) + val profiles = if (storedProfiles.isEmpty()) { + legacyProfile(p)?.let(::listOf).orEmpty() + } else { + storedProfiles + } + Settings( + serverUrl = p[Keys.SERVER_URL], + token = p[Keys.TOKEN], + userId = p[Keys.USER_ID], + serverId = p[Keys.SERVER_ID], + username = p[Keys.USERNAME], + deviceId = p[Keys.DEVICE_ID].orEmpty(), + rotationIntervalSeconds = p[Keys.ROTATION_SECONDS] ?: Settings.DEFAULT_ROTATION_SECONDS, + updateBaseUrl = p[Keys.UPDATE_BASE_URL], + updateRepo = p[Keys.UPDATE_REPO], + updateToken = p[Keys.UPDATE_TOKEN], + showTitleLogo = p[Keys.SHOW_TITLE_LOGO] ?: true, + ringColorHex = p[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR, + lastBackdropUrl = p[Keys.LAST_BACKDROP_URL], + homeSections = p[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS, + homeCacheJson = p[Keys.HOME_CACHE], + homeCardDensity = p[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY, + showHomeCardMetadata = p[Keys.SHOW_HOME_CARD_METADATA] ?: true, + profiles = profiles, + ) + } + + suspend fun setRotationIntervalSeconds(seconds: Int) { + context.dataStore.edit { it[Keys.ROTATION_SECONDS] = seconds } + } + + /** Persists the Gitea update source. Blank values are cleared. */ + suspend fun setUpdateConfig(baseUrl: String, repo: String, token: String) { + context.dataStore.edit { + fun put(key: Preferences.Key, value: String) { + val v = value.trim() + if (v.isEmpty()) it.remove(key) else it[key] = v + } + put(Keys.UPDATE_BASE_URL, baseUrl) + put(Keys.UPDATE_REPO, repo) + put(Keys.UPDATE_TOKEN, token) + } + } + + suspend fun setShowTitleLogo(enabled: Boolean) { + context.dataStore.edit { it[Keys.SHOW_TITLE_LOGO] = enabled } + } + + suspend fun setRingColor(hex: String) { + context.dataStore.edit { it[Keys.RING_COLOR] = hex } + } + + suspend fun setLastBackdropUrl(url: String) { + context.dataStore.edit { it[Keys.LAST_BACKDROP_URL] = url } + } + + suspend fun setHomeSections(sections: List) { + val valid = sections.filter { it in setOf("continue", "favorites", "latest") }.distinct() + context.dataStore.edit { + it[Keys.HOME_SECTIONS] = valid.ifEmpty { listOf("favorites") }.joinToString(",") + } + } + + suspend fun setHomeCardDensity(density: String) { + context.dataStore.edit { + it[Keys.HOME_CARD_DENSITY] = density.takeIf { value -> value in setOf("compact", "standard", "large") } + ?: Settings.DEFAULT_HOME_CARD_DENSITY + } + } + + suspend fun setShowHomeCardMetadata(show: Boolean) { + context.dataStore.edit { it[Keys.SHOW_HOME_CARD_METADATA] = show } + } + + suspend fun setHomeCache(cache: HomeCache) { + context.dataStore.edit { preferences -> + val encodedCache = Json.encodeToString(cache) + preferences[Keys.HOME_CACHE] = encodedCache + val activeUserId = preferences[Keys.USER_ID] + val activeServer = preferences[Keys.SERVER_URL] + val profiles = profilesFrom(preferences).map { profile -> + if (profile.userId == activeUserId && profile.serverUrl == activeServer) { + profile.copy(homeCacheJson = encodedCache) + } else { + profile + } + } + if (profiles.isNotEmpty()) { + preferences[Keys.PROFILES] = Json.encodeToString(profiles) + } + } + } + + fun homeCache(settings: Settings): HomeCache? = settings.homeCacheJson?.let { + runCatching { Json.decodeFromString(it) }.getOrNull() + } + + /** Reads a one-shot snapshot of the current settings. */ + suspend fun snapshot(): Settings = settingsFlow.first() + + /** Returns the stable device id, generating and persisting one on first use. */ + suspend fun ensureDeviceId(): String { + val existing = context.dataStore.data.first()[Keys.DEVICE_ID] + if (!existing.isNullOrBlank()) return existing + val generated = UUID.randomUUID().toString() + context.dataStore.edit { it[Keys.DEVICE_ID] = generated } + return generated + } + + suspend fun saveSession(serverUrl: String, token: String, userId: String, username: String, serverId: String?) { + context.dataStore.edit { preferences -> + val profiles = profilesFrom(preferences).toMutableList() + val id = profileId(serverUrl, userId) + val previous = profiles.firstOrNull { it.id == id } + val profile = EmbyProfile( + id = id, + serverUrl = serverUrl, + token = token, + userId = userId, + username = username, + serverId = serverId, + homeCacheJson = previous?.homeCacheJson, + ) + profiles.removeAll { it.id == id } + profiles.add(profile) + preferences[Keys.PROFILES] = Json.encodeToString(profiles) + applyProfile(preferences, profile) + } + } + + suspend fun switchProfile(profile: EmbyProfile) { + context.dataStore.edit { preferences -> + val profiles = profilesFrom(preferences).toMutableList() + if (profiles.none { it.id == profile.id }) { + profiles.add(profile) + preferences[Keys.PROFILES] = Json.encodeToString(profiles) + } + applyProfile(preferences, profile) + } + } + + suspend fun clearSession() { + context.dataStore.edit { + it.remove(Keys.SERVER_URL) + it.remove(Keys.TOKEN) + it.remove(Keys.USER_ID) + it.remove(Keys.SERVER_ID) + it.remove(Keys.LAST_BACKDROP_URL) + it.remove(Keys.HOME_CACHE) + it.remove(Keys.USERNAME) + // Intentionally keep DEVICE_ID stable across sign-outs. + } + } + + private fun applyProfile(preferences: MutablePreferences, profile: EmbyProfile) { + preferences[Keys.SERVER_URL] = profile.serverUrl + preferences[Keys.TOKEN] = profile.token + preferences[Keys.USER_ID] = profile.userId + preferences[Keys.USERNAME] = profile.username + if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID) + else preferences[Keys.SERVER_ID] = profile.serverId + if (profile.homeCacheJson.isNullOrBlank()) preferences.remove(Keys.HOME_CACHE) + else preferences[Keys.HOME_CACHE] = profile.homeCacheJson + preferences.remove(Keys.LAST_BACKDROP_URL) + } + + private fun legacyProfile(preferences: Preferences): EmbyProfile? { + val serverUrl = preferences[Keys.SERVER_URL] ?: return null + val token = preferences[Keys.TOKEN] ?: return null + val userId = preferences[Keys.USER_ID] ?: return null + val username = preferences[Keys.USERNAME] ?: return null + return EmbyProfile( + id = profileId(serverUrl, userId), + serverUrl = serverUrl, + token = token, + userId = userId, + username = username, + serverId = preferences[Keys.SERVER_ID], + homeCacheJson = preferences[Keys.HOME_CACHE], + ) + } + + private fun decodeProfiles(value: String?): List = + value?.let { runCatching { Json.decodeFromString>(it) }.getOrNull() }.orEmpty() + + private fun profilesFrom(preferences: Preferences): List = + decodeProfiles(preferences[Keys.PROFILES]).ifEmpty { + legacyProfile(preferences)?.let(::listOf).orEmpty() + } + + private fun profileId(serverUrl: String, userId: String): String = "${serverUrl.trimEnd('/')}::$userId" +} + +/** The last successful home response, kept locally for instant launcher startup. */ +@Serializable +data class HomeCache( + val continueWatching: List = emptyList(), + val nextUp: List = emptyList(), + val favorites: List = emptyList(), + val latestMovies: List = emptyList(), + /** + * Server-composed rows, including recommendations, so a cold start redraws the exact + * home screen the gateway last sent. Defaulted, so a cache written by an older build + * still decodes. + */ + val rows: List = emptyList(), +) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/analytics/RowAnalytics.kt b/app/src/main/java/com/ponzischeme89/memby/data/analytics/RowAnalytics.kt new file mode 100644 index 0000000..d88d6c0 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/analytics/RowAnalytics.kt @@ -0,0 +1,153 @@ +package com.ponzischeme89.memby.data.analytics + +import com.ponzischeme89.memby.data.model.GatewayRowEvent +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * Collects what the viewer actually looked at, one row at a time. + * + * Three signals, cheapest to strongest: + * + * - **impression** — the row was composed, so it was on screen at least briefly. + * - **focus** — the remote landed on it, with how long it stayed. This is the number + * worth reading: it separates "a row scrolled past" from "a row someone browsed". + * - **select** — something was opened from it. + * + * Events buffer in memory and are flushed in batches, because a D-pad generates focus + * changes far faster than anything should generate HTTP requests. Nothing here retries or + * persists: losing a batch to a crash costs a little telemetry and nothing else. + */ +class RowAnalytics( + private val now: () -> Long = System::currentTimeMillis, + private val maxBuffered: Int = 200, +) { + private val lock = Any() + private val buffer = ArrayList() + private val impressed = HashSet() + + private var focusedRowId: String? = null + private var focusedRowKind: String = "" + private var focusStartedAt: Long = 0 + + /** Records that a row was drawn. Repeats are ignored until [reset]. */ + fun rowImpression(rowId: String, rowKind: String) { + synchronized(lock) { + if (!impressed.add(rowId)) return + add(GatewayRowEvent(rowId = rowId, rowKind = rowKind, event = EVENT_IMPRESSION, occurredAt = timestamp())) + } + } + + /** + * Records focus landing on [rowId]. Moving between cards inside one row extends that + * row's dwell rather than starting a new measurement — the viewer is still reading + * the same strip. + */ + fun rowFocused(rowId: String, rowKind: String, itemId: String) { + synchronized(lock) { + if (focusedRowId == rowId) return + closeOpenFocus() + focusedRowId = rowId + focusedRowKind = rowKind + focusStartedAt = now() + // The impression may not have fired if the row was already on screen when + // this session's collector was created. + if (impressed.add(rowId)) { + add(GatewayRowEvent(rowId = rowId, rowKind = rowKind, event = EVENT_IMPRESSION, occurredAt = timestamp())) + } + lastFocusedItemId = itemId + } + } + + /** Records something being opened from a row. */ + fun rowSelected(rowId: String, rowKind: String, itemId: String) { + synchronized(lock) { + add( + GatewayRowEvent( + rowId = rowId, + rowKind = rowKind, + event = EVENT_SELECT, + itemId = itemId, + occurredAt = timestamp(), + ), + ) + } + } + + /** + * Closes the open focus measurement — call when leaving the home screen, or before a + * flush, so dwell is not lost while the viewer sits on one row. + */ + fun endFocus() { + synchronized(lock) { closeOpenFocus() } + } + + /** Returns everything buffered and clears it. */ + fun drain(): List = synchronized(lock) { + if (buffer.isEmpty()) return emptyList() + val events = buffer.toList() + buffer.clear() + events + } + + fun hasPending(): Boolean = synchronized(lock) { buffer.isNotEmpty() } + + /** Forgets which rows have been seen, e.g. after a profile switch. */ + fun reset() { + synchronized(lock) { + buffer.clear() + impressed.clear() + focusedRowId = null + } + } + + private var lastFocusedItemId: String = "" + + private fun closeOpenFocus() { + val rowId = focusedRowId ?: return + val dwell = (now() - focusStartedAt).coerceAtLeast(0) + focusedRowId = null + // Sub-second glances are D-pad travel, not attention. Dropping them keeps the + // numbers meaningful and the batches small. + if (dwell < MIN_DWELL_MS) return + add( + GatewayRowEvent( + rowId = rowId, + rowKind = focusedRowKind, + event = EVENT_FOCUS, + itemId = lastFocusedItemId, + dwellMs = dwell, + occurredAt = timestamp(), + ), + ) + } + + /** Caller already holds the lock. */ + private fun add(event: GatewayRowEvent) { + // Drop oldest rather than grow without bound: if flushes are failing, recent + // engagement is the more useful half to keep. + if (buffer.size >= maxBuffered) buffer.removeAt(0) + buffer.add(event) + } + + private fun timestamp(): String = iso8601.get()!!.format(Date(now())) + + companion object { + const val EVENT_IMPRESSION = "impression" + const val EVENT_FOCUS = "focus" + const val EVENT_SELECT = "select" + + /** Below this, a row was passed through rather than looked at. */ + const val MIN_DWELL_MS = 400L + + // SimpleDateFormat is not thread-safe and minSdk 23 rules out java.time. + private val iso8601 = object : ThreadLocal() { + override fun initialValue(): SimpleDateFormat = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt new file mode 100644 index 0000000..253e0f8 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt @@ -0,0 +1,98 @@ +package com.ponzischeme89.memby.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AuthRequest( + @SerialName("Username") val username: String, + @SerialName("Pw") val pw: String, +) + +@Serializable +data class AuthResult( + @SerialName("User") val user: EmbyUser? = null, + @SerialName("AccessToken") val accessToken: String? = null, + @SerialName("ServerId") val serverId: String? = null, +) + +@Serializable +data class EmbyUser( + @SerialName("Id") val id: String, + @SerialName("Name") val name: String? = null, +) + +@Serializable +data class ItemsResult( + @SerialName("Items") val items: List = emptyList(), + @SerialName("TotalRecordCount") val totalRecordCount: Int = 0, +) + +@Serializable +data class UserItemData( + @SerialName("IsFavorite") val isFavorite: Boolean = false, + @SerialName("Played") val played: Boolean = false, + @SerialName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0, +) + +@Serializable +data class PlaybackReport( + @SerialName("ItemId") val itemId: String, + @SerialName("PositionTicks") val positionTicks: Long = 0, + @SerialName("IsPaused") val isPaused: Boolean = false, + @SerialName("IsMuted") val isMuted: Boolean = false, + @SerialName("CanSeek") val canSeek: Boolean = true, + @SerialName("PlayMethod") val playMethod: String = "DirectPlay", +) + +@Serializable +data class Studio( + @SerialName("Name") val name: String = "", +) + +@Serializable +data class MediaStream( + @SerialName("Type") val type: String = "", + @SerialName("Codec") val codec: String? = null, + @SerialName("Title") val title: String? = null, + @SerialName("Width") val width: Int? = null, + @SerialName("Height") val height: Int? = null, + @SerialName("VideoRange") val videoRange: String? = null, + @SerialName("VideoRangeType") val videoRangeType: String? = null, + @SerialName("Channels") val channels: Int? = null, +) + +@Serializable +data class BaseItem( + @SerialName("Id") val id: String, + @SerialName("Name") val name: String = "", + @SerialName("Type") val type: String = "", + @SerialName("Overview") val overview: String? = null, + @SerialName("Taglines") val taglines: List = emptyList(), + @SerialName("ProductionYear") val productionYear: Int? = null, + @SerialName("OfficialRating") val officialRating: String? = null, + @SerialName("CommunityRating") val communityRating: Double? = null, + @SerialName("Studios") val studios: List = emptyList(), + @SerialName("RunTimeTicks") val runTimeTicks: Long? = null, + @SerialName("Genres") val genres: List = emptyList(), + @SerialName("MediaStreams") val mediaStreams: List = emptyList(), + @SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null, + @SerialName("BackdropImageTags") val backdropImageTags: List = emptyList(), + @SerialName("ImageTags") val imageTags: Map = emptyMap(), + @SerialName("SeriesId") val seriesId: String? = null, + @SerialName("SeriesName") val seriesName: String? = null, + @SerialName("ParentBackdropItemId") val parentBackdropItemId: String? = null, + @SerialName("ParentBackdropImageTags") val parentBackdropImageTags: List = emptyList(), + @SerialName("ParentLogoItemId") val parentLogoItemId: String? = null, + @SerialName("ParentLogoImageTag") val parentLogoImageTag: String? = null, + @SerialName("UserData") val userData: UserItemData? = null, +) { + val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true) + val isSeries: Boolean get() = type.equals("Series", ignoreCase = true) + val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true) + val isFavorite: Boolean get() = userData?.isFavorite == true + + /** Runtime in whole minutes, or null when unknown. */ + val runtimeMinutes: Int? + get() = runTimeTicks?.let { (it / 600_000_000L).toInt() }.takeIf { it != null && it > 0 } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt new file mode 100644 index 0000000..5f2db35 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -0,0 +1,105 @@ +package com.ponzischeme89.memby.data.model + +import kotlinx.serialization.Serializable + +/** + * Wire types for the Memby gateway. + * + * Item payloads are deliberately still [BaseItem]: the gateway forwards Emby's item JSON + * untouched, so there is exactly one item schema in the system regardless of which + * backend the client is talking to. + */ + +@Serializable +data class GatewayLoginRequest( + val username: String, + val password: String, + val deviceId: String, +) + +@Serializable +data class GatewayLoginResponse( + val token: String = "", + val userId: String = "", + val username: String = "", + val serverId: String = "", +) + +/** + * One horizontal strip, described entirely by the server. + * + * [kind] drives card shape and the empty-state wording on the client; [id] is the stable + * key Compose uses for the row. A server that starts sending a new row — a recommendation + * strip, a seasonal collection — needs no client release, as long as its kind is one the + * app already understands (unknown kinds fall back to poster cards). + * + * The same type is persisted in [com.ponzischeme89.memby.data.HomeCache], so a cold + * start redraws the exact rows the server last sent. + */ +@Serializable +data class HomeRow( + val id: String, + val title: String, + val kind: String = "", + val items: List = emptyList(), +) + +/** Everything the launcher renders, in one response. */ +@Serializable +data class GatewayHome( + /** Server-composed rows, in display order. */ + val rows: List = emptyList(), + val continueWatching: List = emptyList(), + val nextUp: List = emptyList(), + val favorites: List = emptyList(), + val latestMovies: List = emptyList(), + /** True when a row failed upstream; the rest of the payload is still usable. */ + val partial: Boolean = false, +) + +/** Response of `GET /v1/recommendations`. */ +@Serializable +data class GatewayRows( + val rows: List = emptyList(), +) + +@Serializable +data class GatewayItems( + val items: List = emptyList(), +) + +@Serializable +data class GatewayPlayback( + val itemId: String, + val title: String = "", + val url: String, + val resumePositionMs: Long = 0, +) + +@Serializable +data class GatewayFlagRequest( + val value: Boolean, +) + +/** One row-engagement event. See `data/analytics/RowAnalytics.kt`. */ +@Serializable +data class GatewayRowEvent( + val rowId: String, + val rowKind: String = "", + val event: String, + val itemId: String = "", + val dwellMs: Long = 0, + val occurredAt: String = "", +) + +@Serializable +data class GatewayRowEvents( + val events: List, +) + +@Serializable +data class GatewayPlaybackReport( + val itemId: String, + val positionMs: Long, + val isPaused: Boolean = false, +) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt new file mode 100644 index 0000000..5277d8b --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyApi.kt @@ -0,0 +1,81 @@ +package com.ponzischeme89.memby.data.remote + +import com.ponzischeme89.memby.data.model.AuthRequest +import com.ponzischeme89.memby.data.model.AuthResult +import com.ponzischeme89.memby.data.model.ItemsResult +import com.ponzischeme89.memby.data.model.PlaybackReport +import com.ponzischeme89.memby.data.model.UserItemData +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query +import retrofit2.http.QueryMap + +interface EmbyApi { + + @POST("Users/AuthenticateByName") + suspend fun authenticate(@Body body: AuthRequest): AuthResult + + @GET("Users/{userId}/Items") + suspend fun getItems( + @Path("userId") userId: String, + @QueryMap params: Map, + ): ItemsResult + + @GET("Users/{userId}/Items/{itemId}") + suspend fun getItem( + @Path("userId") userId: String, + @Path("itemId") itemId: String, + @Query("Fields") fields: String, + ): com.ponzischeme89.memby.data.model.BaseItem + + @GET("Users/{userId}/Items/{itemId}/LocalTrailers") + suspend fun getLocalTrailers( + @Path("userId") userId: String, + @Path("itemId") itemId: String, + ): ItemsResult + + @GET("Shows/NextUp") + suspend fun getNextUp(@QueryMap params: Map): ItemsResult + + @POST("Sessions/Playing") + suspend fun reportPlaybackStarted(@Body body: PlaybackReport) + + @POST("Sessions/Playing/Progress") + suspend fun reportPlaybackProgress(@Body body: PlaybackReport) + + @POST("Sessions/Playing/Stopped") + suspend fun reportPlaybackStopped(@Body body: PlaybackReport) + + @GET("Shows/{seriesId}/Episodes") + suspend fun getEpisodes( + @Path("seriesId") seriesId: String, + @QueryMap params: Map, + ): ItemsResult + + @POST("Users/{userId}/FavoriteItems/{itemId}") + suspend fun addFavorite( + @Path("userId") userId: String, + @Path("itemId") itemId: String, + ): UserItemData + + @DELETE("Users/{userId}/FavoriteItems/{itemId}") + suspend fun removeFavorite( + @Path("userId") userId: String, + @Path("itemId") itemId: String, + ): UserItemData + + @POST("Users/{userId}/PlayedItems/{itemId}") + suspend fun markPlayed( + @Path("userId") userId: String, + @Path("itemId") itemId: String, + ): UserItemData + + @DELETE("Users/{userId}/PlayedItems/{itemId}") + suspend fun markUnplayed( + @Path("userId") userId: String, + @Path("itemId") itemId: String, + ): UserItemData +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyServiceFactory.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyServiceFactory.kt new file mode 100644 index 0000000..e00c904 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/EmbyServiceFactory.kt @@ -0,0 +1,78 @@ +package com.ponzischeme89.memby.data.remote + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.ponzischeme89.memby.BuildConfig +import kotlinx.serialization.json.Json +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Response +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit + +/** Builds a [EmbyApi] bound to a specific server base URL. */ +object EmbyServiceFactory { + + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true + } + + fun create( + baseUrl: String, + deviceIdProvider: () -> String, + tokenProvider: () -> String?, + ): EmbyApi { + val contentType = "application/json".toMediaType() + + // Kept at NONE so access tokens (carried in the api_key query param and + // X-Emby-Token header) are never written to logs. Raise deliberately for + // local debugging only. + val logging = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.NONE + redactHeader("X-Emby-Token") + redactHeader("X-Emby-Authorization") + } + + val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider)) + .addInterceptor(logging) + .build() + + return Retrofit.Builder() + .baseUrl(baseUrl.ensureTrailingSlash()) + .client(client) + .addConverterFactory(json.asConverterFactory(contentType)) + .build() + .create(EmbyApi::class.java) + } +} + +/** Adds the Emby auth headers to every request. */ +private class EmbyAuthInterceptor( + private val deviceIdProvider: () -> String, + private val tokenProvider: () -> String?, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val deviceId = deviceIdProvider() + // Version comes from the build, so Emby's device list shows which release a TV + // is actually running. + val authHeader = "MediaBrowser Client=\"Memby\", " + + "Device=\"Android TV\", DeviceId=\"$deviceId\", Version=\"${BuildConfig.VERSION_NAME}\"" + + val builder = chain.request().newBuilder() + .header("X-Emby-Authorization", authHeader) + .header("Accept", "application/json") + + tokenProvider()?.takeIf { it.isNotBlank() }?.let { + builder.header("X-Emby-Token", it) + } + return chain.proceed(builder.build()) + } +} + +fun String.ensureTrailingSlash(): String = if (endsWith("/")) this else "$this/" diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt new file mode 100644 index 0000000..dd61980 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -0,0 +1,69 @@ +package com.ponzischeme89.memby.data.remote + +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.GatewayFlagRequest +import com.ponzischeme89.memby.data.model.GatewayHome +import com.ponzischeme89.memby.data.model.GatewayItems +import com.ponzischeme89.memby.data.model.GatewayLoginRequest +import com.ponzischeme89.memby.data.model.GatewayLoginResponse +import com.ponzischeme89.memby.data.model.GatewayPlayback +import com.ponzischeme89.memby.data.model.GatewayPlaybackReport +import com.ponzischeme89.memby.data.model.GatewayRowEvents +import com.ponzischeme89.memby.data.model.GatewayRows +import com.ponzischeme89.memby.data.model.UserItemData +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * The Memby gateway API. + * + * Note how much smaller this is than [EmbyApi]: work the client used to do — fanning out + * four home queries, picking an episode for a series, deciding which fields to request — + * now happens server-side, which is the entire point of the gateway. + */ +interface GatewayApi { + + @POST("v1/auth/login") + suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse + + @POST("v1/auth/logout") + suspend fun logout() + + @GET("v1/home") + suspend fun home(@Query("limit") limit: Int): GatewayHome + + @GET("v1/screensaver") + suspend fun screensaver(@Query("limit") limit: Int): GatewayItems + + @GET("v1/search") + suspend fun search(@Query("q") term: String, @Query("limit") limit: Int): GatewayItems + + /** Recommendation rows on their own. `/v1/home` already embeds these when warm. */ + @GET("v1/recommendations") + suspend fun recommendations(): GatewayRows + + @GET("v1/items/{id}") + suspend fun item(@Path("id") itemId: String): BaseItem + + @GET("v1/items/{id}/playback") + suspend fun playback(@Path("id") itemId: String): GatewayPlayback + + @GET("v1/items/{id}/trailer") + suspend fun trailer(@Path("id") itemId: String): BaseItem + + @POST("v1/items/{id}/favorite") + suspend fun setFavorite(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData + + @POST("v1/items/{id}/played") + suspend fun setPlayed(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData + + @POST("v1/playback/{phase}") + suspend fun report(@Path("phase") phase: String, @Body body: GatewayPlaybackReport) + + /** Row engagement, uploaded in batches. Fire-and-forget: failures are not retried. */ + @POST("v1/analytics/rows") + suspend fun reportRowEvents(@Body body: GatewayRowEvents) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt new file mode 100644 index 0000000..8fbcc8b --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt @@ -0,0 +1,54 @@ +package com.ponzischeme89.memby.data.remote + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.serialization.json.Json +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Response +import retrofit2.Retrofit +import java.util.concurrent.TimeUnit + +/** Builds a [GatewayApi] bound to a Memby gateway. */ +object GatewayServiceFactory { + + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true + explicitNulls = false + } + + fun create(baseUrl: String, tokenProvider: () -> String?): GatewayApi { + val contentType = "application/json".toMediaType() + + val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + // The gateway answers home from Redis in single-digit milliseconds; a long + // read timeout here only ever means Emby itself is struggling behind it. + .readTimeout(20, TimeUnit.SECONDS) + .addInterceptor(GatewayAuthInterceptor(tokenProvider)) + .build() + + return Retrofit.Builder() + .baseUrl(baseUrl.ensureTrailingSlash()) + .client(client) + .addConverterFactory(json.asConverterFactory(contentType)) + .build() + .create(GatewayApi::class.java) + } +} + +/** + * Sends the gateway token as a bearer header. Image URLs cannot carry headers, so those + * are built with a `t=` query parameter instead (see EmbyRepository's URL helpers). + */ +private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val builder = chain.request().newBuilder().header("Accept", "application/json") + tokenProvider()?.takeIf { it.isNotBlank() }?.let { + builder.header("Authorization", "Bearer $it") + } + return chain.proceed(builder.build()) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/performance/PerformanceMonitor.kt b/app/src/main/java/com/ponzischeme89/memby/performance/PerformanceMonitor.kt new file mode 100644 index 0000000..7544bd7 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/performance/PerformanceMonitor.kt @@ -0,0 +1,49 @@ +package com.ponzischeme89.memby.performance + +import android.app.Activity +import android.os.SystemClock +import android.util.Log +import androidx.metrics.performance.JankStats + +/** Debug-only frame telemetry. It does not alter rendering or app state. */ +object PerformanceMonitor { + private const val TAG = "EmbyClientPerf" + private var stats: JankStats? = null + private var frameCount = 0 + private var jankCount = 0 + private var totalFrameMs = 0L + private var windowStartedAt = 0L + + fun start(activity: Activity) { + if (!com.ponzischeme89.memby.BuildConfig.DEBUG || stats != null) return + activity.window.decorView.post { + if (stats != null) return@post + windowStartedAt = SystemClock.elapsedRealtime() + stats = JankStats.createAndTrack(activity.window) { frameData -> + frameCount++ + totalFrameMs += frameData.frameDurationUiNanos / 1_000_000L + if (frameData.isJank) jankCount++ + if (frameCount % 120 == 0) report("window") + } + Log.i(TAG, "tracking started") + } + } + + fun mark(name: String) { + if (stats == null) return + report(name) + frameCount = 0 + jankCount = 0 + totalFrameMs = 0 + windowStartedAt = SystemClock.elapsedRealtime() + } + + private fun report(name: String) { + if (frameCount == 0) return + val elapsed = SystemClock.elapsedRealtime() - windowStartedAt + Log.i( + TAG, + "$name frames=$frameCount jank=$jankCount avgUiMs=${totalFrameMs / frameCount} elapsedMs=$elapsed", + ) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt b/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt new file mode 100644 index 0000000..4b059a9 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/screensaver/MembyDreamService.kt @@ -0,0 +1,154 @@ +package com.ponzischeme89.memby.screensaver + +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.service.dreams.DreamService +import android.view.KeyEvent +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.ponzischeme89.memby.ui.player.PlayerActivity +import com.ponzischeme89.memby.ui.screensaver.ScreensaverActions +import com.ponzischeme89.memby.ui.screensaver.ScreensaverContent +import com.ponzischeme89.memby.ui.theme.MembyTheme + +/** + * The Android TV / Google TV system screensaver (Ambient mode source). + * + * Hosts the shared [ScreensaverContent] composable inside a [ComposeView]. Because + * a DreamService is not a ComponentActivity, we supply the ViewTree owners Compose + * requires via [DreamLifecycleOwner]. The saved Emby session is read from the shared + * repository/DataStore, so this works with no Activity running and after process death. + * + * D-pad navigation, panel toggling and OK-to-act are handled inside the composable + * (via Compose focus + key events). The remote's Play/Pause media key is intercepted + * here in [dispatchKeyEvent] and routed to the composable through [ScreensaverActions]. + */ +class MembyDreamService : DreamService() { + + private var owner: DreamLifecycleOwner? = null + private var composeView: ComposeView? = null + private val actions = ScreensaverActions() + private val mainHandler = Handler(Looper.getMainLooper()) + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + isFullscreen = true + isInteractive = true + isScreenBright = true + + val lifecycleOwner = DreamLifecycleOwner().also { it.onCreate() } + owner = lifecycleOwner + + composeView = ComposeView(this).apply { + setViewTreeLifecycleOwner(lifecycleOwner) + setViewTreeViewModelStoreOwner(lifecycleOwner) + setViewTreeSavedStateRegistryOwner(lifecycleOwner) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + MembyTheme { + ScreensaverContent( + onPlay = { url, title -> launchPlayback(url, title) }, + onExit = { finish() }, + actions = actions, + ) + } + } + } + setContentView(composeView) + + lifecycleOwner.onStart() + } + + override fun onDreamingStarted() { + super.onDreamingStarted() + owner?.onResume() + } + + override fun onDreamingStopped() { + owner?.onPause() + super.onDreamingStopped() + } + + override fun onDetachedFromWindow() { + // Cancels Compose coroutines (rotation timer, network calls) by disposing + // the composition, then tears down the owner and releases callbacks. + actions.playCurrent = null + mainHandler.removeCallbacksAndMessages(null) + owner?.onDestroy() + owner = null + composeView = null + super.onDetachedFromWindow() + } + + /** Route the hardware Play/Pause media key to the current item; delegate the rest. */ + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN) { + when (event.keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY, + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { + actions.playCurrent?.invoke() + return true + } + } + } + return super.dispatchKeyEvent(event) + } + + /** + * Exit the dream, then start playback. Launching the player *after* the dream + * finishes (via a short main-thread post) avoids the "activity started behind + * the dream" race some TV builds exhibit. Uses the application context because + * this service is being torn down. + */ + private fun launchPlayback(url: String, title: String) { + val intent = PlayerActivity.intent(applicationContext, url, title) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + finish() + mainHandler.postDelayed({ + runCatching { applicationContext.startActivity(intent) } + }, 150) + } +} + +/** Minimal owner bundle so Compose can run inside a DreamService window. */ +private class DreamLifecycleOwner : LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner { + + private val lifecycleRegistry = LifecycleRegistry(this) + private val store = ViewModelStore() + private val savedStateController = SavedStateRegistryController.create(this) + + override val lifecycle: Lifecycle get() = lifecycleRegistry + override val viewModelStore: ViewModelStore get() = store + override val savedStateRegistry: SavedStateRegistry get() = savedStateController.savedStateRegistry + + fun onCreate() { + savedStateController.performRestore(null) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + } + + fun onStart() = lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + fun onResume() = lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + fun onPause() = lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) + + fun onDestroy() { + // Only step down from whatever state we're in; guard against double-destroy. + if (lifecycleRegistry.currentState.isAtLeast(Lifecycle.State.STARTED)) { + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_STOP) + } + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) + store.clear() + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt new file mode 100644 index 0000000..e901cbe --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -0,0 +1,1159 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onLongClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Movie +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Tv +import androidx.tv.material3.Icon +import androidx.tv.material3.Button +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.R +import com.ponzischeme89.memby.data.model.BaseItem +import java.util.Locale +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val EmbyGreen = Color(0xFF52B54B) +private val RailSurface = Color(0xF20C0F12) +private val MutedText = Color(0xFFB7BDC3) +private val QuietText = Color(0xFF8C949B) +internal val TvRailCollapsedWidth = 54.dp +internal val TvRailExpandedWidth = 184.dp +internal val TvRailContentShift = 112.dp + +enum class BrowseDestination(val label: String, val icon: ImageVector) { + HOME("Home", Icons.Default.Home), + MOVIES("Movies", Icons.Default.Movie), + SHOWS("TV Shows", Icons.Default.Tv), + FAVORITES("Favourites", Icons.Default.Favorite), + PROFILES("Switch user", Icons.Default.Person), + SETTINGS("Settings", Icons.Default.Settings), +} + +enum class MediaRowKind { CONTINUE, NEXT_UP, MOVIES, SHOWS, FAVORITES } + +data class HomeBrowseRow( + val id: String, + val title: String, + val items: List, + val kind: MediaRowKind, + val loading: Boolean = false, + val emptyMessage: String, + val showSecondaryMetadata: Boolean = true, +) + +@Composable +fun TvNavigationRail( + selected: BrowseDestination, + expanded: Boolean, + navigationFocusRequester: FocusRequester, + contentFocusRequester: FocusRequester, + onRailFocusChanged: (Boolean) -> Unit, + onDestinationSelected: (BrowseDestination) -> Unit, + modifier: Modifier = Modifier, +) { + val railWidth by animateDpAsState( + targetValue = if (expanded) TvRailExpandedWidth else TvRailCollapsedWidth, + animationSpec = tween(160), + label = "navigation-rail-width", + ) + Box( + modifier = modifier + .width(TvRailCollapsedWidth) + .fillMaxHeight() + .zIndex(8f), + ) { + Column( + modifier = Modifier + // The parent deliberately reports only the collapsed footprint to the + // home Row. requiredWidth lets the focused surface draw outward without + // remeasuring gallery cards or clipping their labels to 54dp. + .wrapContentSize(Alignment.TopStart, unbounded = true) + .requiredWidth(railWidth) + .fillMaxHeight() + .background(RailSurface) + .padding(horizontal = 5.dp, vertical = 15.dp), + horizontalAlignment = Alignment.Start, + ) { + Row( + modifier = Modifier.height(42.dp).padding(horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Image( + painter = painterResource(R.drawable.emby_logo), + contentDescription = "Emby", + modifier = Modifier.width(32.dp).height(27.dp), + ) + if (expanded) { + Text("Emby", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold) + } + } + Spacer(Modifier.height(16.dp)) + BrowseDestination.entries.forEach { destination -> + ExpandableNavigationItem( + destination = destination, + selected = destination == selected, + expanded = expanded, + modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier, + contentFocusRequester = contentFocusRequester, + onFocused = { onRailFocusChanged(true) }, + onClick = { onDestinationSelected(destination) }, + ) + Spacer(Modifier.height(4.dp)) + } + } + } +} + +@Composable +fun ExpandableNavigationItem( + destination: BrowseDestination, + selected: Boolean, + expanded: Boolean, + contentFocusRequester: FocusRequester, + onFocused: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var focused by remember { mutableStateOf(false) } + val background by animateColorAsState( + targetValue = when { + focused -> Color.White.copy(alpha = 0.13f) + selected -> EmbyGreen.copy(alpha = 0.10f) + else -> Color.Transparent + }, + animationSpec = tween(85), + label = "navigation-item-background", + ) + val foreground by animateColorAsState( + targetValue = when { + focused -> Color.White + selected -> EmbyGreen + else -> MutedText + }, + animationSpec = tween(85), + label = "navigation-item-foreground", + ) + Row( + modifier = modifier + .fillMaxWidth() + .height(44.dp) + .focusProperties { right = contentFocusRequester } + .onFocusChanged { + focused = it.isFocused + if (it.isFocused) onFocused() + } + .clip(RoundedCornerShape(8.dp)) + .background(background) + .clickable(onClick = onClick) + .semantics { contentDescription = destination.label } + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) { + Icon(destination.icon, contentDescription = null, tint = foreground, modifier = Modifier.size(21.dp)) + if (selected) { + Box( + Modifier + .align(Alignment.CenterStart) + .width(2.dp) + .height(18.dp) + .background(EmbyGreen, RoundedCornerShape(2.dp)), + ) + } + } + if (expanded) { + Text( + destination.label, + color = foreground, + fontSize = 15.sp, + fontWeight = if (focused || selected) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 1, + ) + } + } +} + +@Composable +fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) { + val context = LocalContext.current + val repo = ServiceLocator.repository + val imageUrl = item?.let { repo.backdropUrl(it, 1280) ?: repo.primaryUrl(it, 960) } + var displayedUrl by remember { mutableStateOf(imageUrl) } + LaunchedEffect(imageUrl) { + if (displayedUrl == null) { + displayedUrl = imageUrl + } else { + delay(BACKDROP_SETTLE_DELAY_MS) + displayedUrl = imageUrl + } + } + val request = remember(displayedUrl, context) { + displayedUrl?.let { + ImageRequest.Builder(context) + .data(it) + .size(1280, 720) + .allowHardware(true) + .crossfade(false) + .build() + } + } + Box(modifier.background(Color(0xFF090B0D))) { + if (request != null) { + AsyncImage( + model = request, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + Box( + Modifier.fillMaxSize().background( + Brush.horizontalGradient( + 0f to Color(0xFF090B0D), + 0.58f to Color(0xE3090B0D), + 1f to Color(0xA6090B0D), + ), + ), + ) + Box( + Modifier.fillMaxSize().background( + Brush.verticalGradient( + 0f to Color(0x73090B0D), + 0.66f to Color(0xD6090B0D), + 1f to Color(0xFF090B0D), + ), + ), + ) + } +} + +@Composable +fun MediaMetadataPanel( + item: BaseItem?, + loading: Boolean, + sectionLabel: String, + contentFocusRequester: FocusRequester, + navigationFocusRequester: FocusRequester, + onPlay: (BaseItem) -> Unit, + onContentFocused: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier) { + Box(Modifier.weight(1f)) { + when { + item == null && loading -> MetadataLoadingSkeleton() + item == null -> { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(sectionLabel, color = QuietText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold) + Text("Your library", color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.SemiBold) + Text("Choose something to watch.", color = MutedText, fontSize = 16.sp) + } + } + else -> MetadataContent(item, sectionLabel) + } + } + Button( + onClick = { item?.let(onPlay) }, + enabled = item != null, + modifier = Modifier + .focusRequester(contentFocusRequester) + .focusProperties { left = navigationFocusRequester } + .onFocusChanged { if (it.isFocused) onContentFocused() }, + ) { + val resumable = (item?.userData?.playbackPositionTicks ?: 0L) > 0L + Text(if (resumable) "▶ Resume" else "▶ Play") + } + } +} + +@Composable +private fun MetadataLoadingSkeleton() { + Column( + modifier = Modifier.fillMaxWidth(0.68f), + verticalArrangement = Arrangement.spacedBy(11.dp), + ) { + SkeletonBlock(82.dp, 11.dp) + SkeletonBlock(360.dp, 30.dp) + SkeletonBlock(270.dp, 14.dp) + Spacer(Modifier.height(2.dp)) + SkeletonBlock(520.dp, 13.dp) + SkeletonBlock(470.dp, 13.dp) + } +} + +@Composable +private fun SkeletonBlock(width: Dp, height: Dp) { + Box( + Modifier + .width(width) + .height(height) + .clip(RoundedCornerShape(4.dp)) + .background(Color(0xFF30363B)), + ) +} + +@Composable +fun MediaDetailsOverlay( + item: BaseItem, + onPlay: (BaseItem) -> Unit, + onToggleFavorite: (BaseItem, Boolean) -> Unit, + onTogglePlayed: (BaseItem, Boolean) -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + val firstAction = remember { FocusRequester() } + LaunchedEffect(item.id) { firstAction.requestFocus() } + + Box( + modifier + .fillMaxSize() + .background(Color(0xFF090B0D)), + ) { + BackdropLayer(item, Modifier.fillMaxSize()) + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.22f)), + ) + Column( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(0.68f) + .padding(start = 72.dp, end = 36.dp, top = 60.dp, bottom = 48.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = when { + item.isMovie -> "MOVIE" + item.isSeries -> "SERIES" + item.isEpisode -> "EPISODE" + else -> item.type.uppercase() + }, + color = EmbyGreen, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 1.3.sp, + ) + Spacer(Modifier.height(10.dp)) + Text( + item.name, + color = Color.White, + fontSize = 38.sp, + lineHeight = 42.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(10.dp)) + val facts = listOfNotNull( + item.productionYear?.toString(), + item.runtimeMinutes?.let(::formatTvRuntime), + item.officialRating, + item.communityRating?.let { "★ ${String.format(Locale.US, "%.1f", it)}" }, + item.genres.take(2).joinToString(" · ").takeIf(String::isNotBlank), + ) + Text( + facts.joinToString(" • "), + color = MutedText, + fontSize = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(16.dp)) + Text( + item.overview?.takeIf(String::isNotBlank) ?: "No description available.", + color = Color(0xFFD8DCDF), + fontSize = 17.sp, + lineHeight = 23.sp, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(22.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { onPlay(item) }, + modifier = Modifier.focusRequester(firstAction), + ) { + val resumable = (item.userData?.playbackPositionTicks ?: 0L) > 0L + Text(if (item.isSeries) "▶ Play next episode" else if (resumable) "▶ Resume" else "▶ Play") + } + Button(onClick = { onToggleFavorite(item, !item.isFavorite) }) { + Text(if (item.isFavorite) "Remove favourite" else "Add favourite") + } + Button(onClick = { onTogglePlayed(item, item.userData?.played != true) }) { + Text(if (item.userData?.played == true) "Mark unwatched" else "Mark watched") + } + Button(onClick = onClose) { Text("Close") } + } + } + } +} + +@Composable +fun MediaQuickActionsOverlay( + item: BaseItem, + onSetFavorite: (BaseItem, Boolean) -> Unit, + onSetPlayed: (BaseItem, Boolean) -> Unit, + onClose: () -> Unit, +) { + val firstAction = remember { FocusRequester() } + LaunchedEffect(item.id) { firstAction.requestFocus() } + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .width(520.dp) + .clip(RoundedCornerShape(14.dp)) + .background(Color(0xFF171B1F)) + .border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(14.dp)) + .padding(28.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Quick actions", color = EmbyGreen, fontSize = 13.sp, fontWeight = FontWeight.Bold) + Text( + item.name, + color = Color.White, + fontSize = 25.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(4.dp)) + Button( + onClick = { + onSetFavorite(item, !item.isFavorite) + onClose() + }, + modifier = Modifier.focusRequester(firstAction), + ) { + Text(if (item.isFavorite) "Clear from favourites" else "Add to favourites") + } + Button( + onClick = { + onSetPlayed(item, item.userData?.played != true) + onClose() + }, + ) { + Text(if (item.userData?.played == true) "Mark unwatched" else "Mark watched") + } + Button(onClick = onClose) { Text("Cancel") } + } + } +} + +@Composable +private fun MetadataContent(item: BaseItem, sectionLabel: String) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(7.dp), + ) { + Text(sectionLabel.uppercase(), color = EmbyGreen, fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp) + Text( + item.seriesName?.takeIf { item.isEpisode } ?: item.name, + color = Color.White, + fontSize = 30.sp, + lineHeight = 34.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (item.isEpisode && item.seriesName != null) { + Text(item.name, color = MutedText, fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + val facts = buildList { + item.productionYear?.let { add(it.toString()) } + item.runtimeMinutes?.let { add(formatTvRuntime(it)) } + item.officialRating?.takeIf(String::isNotBlank)?.let(::add) + item.communityRating?.let { add("★ ${String.format(Locale.US, "%.1f", it)}") } + item.genres.take(2).takeIf { it.isNotEmpty() }?.let { add(it.joinToString(" · ")) } + } + if (facts.isNotEmpty()) { + Text(facts.joinToString(" • "), color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + val badges = mediaBadges(item) + if (badges.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) { + badges.forEach { MediaBadge(it) } + } + } + Text( + item.overview?.takeIf(String::isNotBlank) ?: "No description available.", + color = Color(0xFFD0D4D7), + fontSize = 15.sp, + lineHeight = 20.sp, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(0.74f), + ) + MetadataStatus(item) + } +} + +@Composable +private fun MetadataStatus(item: BaseItem) { + val position = item.userData?.playbackPositionTicks ?: 0L + val runtime = item.runTimeTicks ?: 0L + val progress = if (runtime > 0) (position.toFloat() / runtime).coerceIn(0f, 1f) else 0f + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + if (progress > 0f) { + Box( + Modifier.width(150.dp).height(4.dp).background(Color.White.copy(alpha = 0.22f), RoundedCornerShape(2.dp)), + ) { + Box(Modifier.fillMaxWidth(progress).height(4.dp).background(EmbyGreen, RoundedCornerShape(2.dp))) + } + Text("${(progress * 100).toInt()}% watched", color = MutedText, fontSize = 13.sp) + } + if (item.userData?.played == true) { + Icon(Icons.Default.CheckCircle, contentDescription = "Watched", tint = EmbyGreen, modifier = Modifier.size(17.dp)) + Text("Watched", color = MutedText, fontSize = 13.sp) + } + if (item.isFavorite) { + Icon(Icons.Default.Favorite, contentDescription = "Favourite", tint = EmbyGreen, modifier = Modifier.size(17.dp)) + Text("Favourite", color = MutedText, fontSize = 13.sp) + } + } +} + +@Composable +fun MediaBadge(label: String, modifier: Modifier = Modifier) { + Text( + label, + color = Color(0xFFE4E7E9), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = modifier + .border(1.dp, Color.White.copy(alpha = 0.28f), RoundedCornerShape(4.dp)) + .background(Color.Black.copy(alpha = 0.22f), RoundedCornerShape(4.dp)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) +} + +internal fun mediaBadges(item: BaseItem): List { + val video = item.mediaStreams.firstOrNull { it.type.equals("Video", true) } + val audio = item.mediaStreams.firstOrNull { it.type.equals("Audio", true) } + return buildList { + if ((video?.width ?: 0) >= 3_800) add("4K") + val range = listOfNotNull(video?.videoRange, video?.videoRangeType, video?.title).joinToString(" ").lowercase() + when { + "dolby vision" in range || "dovi" in range -> add("DOLBY VISION") + "hdr" in range -> add("HDR") + } + if (video?.codec.equals("hevc", true) || video?.codec.equals("h265", true)) add("HEVC") + if (audio?.title?.contains("atmos", true) == true) add("DOLBY ATMOS") + }.distinct() +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@Composable +fun MediaRow( + row: HomeBrowseRow, + availableWidth: Dp, + navigationFocusRequester: FocusRequester, + contentEntryFocusRequester: FocusRequester?, + returnFocusItemId: String?, + returnFocusRequester: FocusRequester, + onContentFocused: () -> Unit, + onItemFocused: (BaseItem) -> Unit, + onItemSelected: (BaseItem) -> Unit, + onItemLongPressed: (BaseItem) -> Unit, + modifier: Modifier = Modifier, +) { + val rowState = rememberSaveable(row.id, saver = LazyListState.Saver) { LazyListState() } + val scope = rememberCoroutineScope() + val pageSize = if ( + row.items.firstOrNull()?.let { cardFormat(row.kind, it) } == MediaCardFormat.PORTRAIT + ) 6 else 4 + Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + row.title, + color = Color(0xFFF1F3F4), + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.weight(1f)) + if (row.items.isNotEmpty()) { + GalleryJumpButton( + forward = false, + enabled = rowState.firstVisibleItemIndex > 0, + onClick = { + val target = (rowState.firstVisibleItemIndex - pageSize).coerceAtLeast(0) + scope.launch { rowState.scrollToItem(target) } + }, + ) + Spacer(Modifier.width(8.dp)) + GalleryJumpButton( + forward = true, + enabled = rowState.canScrollForward, + onClick = { + val target = (rowState.firstVisibleItemIndex + pageSize) + .coerceAtMost(row.items.lastIndex) + scope.launch { rowState.scrollToItem(target) } + }, + ) + } + } + when { + row.items.isEmpty() && row.loading -> { + LazyRow( + contentPadding = PaddingValues(horizontal = 36.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(5) { + TvLoadingPlaceholder( + portrait = row.kind in setOf(MediaRowKind.MOVIES, MediaRowKind.SHOWS), + availableWidth = availableWidth, + ) + } + } + } + row.items.isEmpty() -> { + Text( + row.emptyMessage, + color = QuietText, + fontSize = 14.sp, + modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp), + ) + } + else -> { + LazyRow( + state = rowState, + contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth().focusGroup().focusRestorer(), + ) { + itemsIndexed( + row.items, + key = { _, item -> item.id }, + contentType = { _, item -> if (cardFormat(row.kind, item) == MediaCardFormat.PORTRAIT) "portrait" else "landscape" }, + ) { index, item -> + var cardModifier: Modifier = Modifier + if (index == 0) { + cardModifier = cardModifier.focusProperties { left = navigationFocusRequester } + if (contentEntryFocusRequester != null) { + cardModifier = cardModifier.focusRequester(contentEntryFocusRequester) + } + } + if (item.id == returnFocusItemId) { + cardModifier = cardModifier.focusRequester(returnFocusRequester) + } + val focused: () -> Unit = { + onContentFocused() + onItemFocused(item) + } + when (cardFormat(row.kind, item)) { + MediaCardFormat.PORTRAIT -> PortraitMediaCard( + item, availableWidth, row.showSecondaryMetadata, focused, + { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, + ) + MediaCardFormat.LANDSCAPE -> if (row.kind == MediaRowKind.CONTINUE) { + ContinueWatchingCard( + item, availableWidth, row.showSecondaryMetadata, focused, + { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, + ) + } else { + LandscapeMediaCard( + item, availableWidth, row.showSecondaryMetadata, focused, + { onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun GalleryJumpButton( + forward: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + FocusScaleContainer( + onFocused = {}, + onClick = { if (enabled) onClick() }, + onLongClick = null, + contentDescription = if (forward) "Next page" else "Previous page", + modifier = Modifier.width(42.dp).height(36.dp), + ) { focused -> + Box( + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(7.dp)) + .background( + when { + focused -> Color.White.copy(alpha = 0.16f) + enabled -> Color.White.copy(alpha = 0.08f) + else -> Color.White.copy(alpha = 0.03f) + }, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (forward) Icons.Default.ChevronRight else Icons.Default.ChevronLeft, + contentDescription = null, + tint = if (enabled) Color.White else QuietText.copy(alpha = 0.45f), + modifier = Modifier.size(23.dp), + ) + } + } +} + +private enum class MediaCardFormat { PORTRAIT, LANDSCAPE } + +private fun cardFormat(kind: MediaRowKind, item: BaseItem): MediaCardFormat = when { + kind == MediaRowKind.CONTINUE || kind == MediaRowKind.NEXT_UP -> MediaCardFormat.LANDSCAPE + item.isEpisode -> MediaCardFormat.LANDSCAPE + else -> MediaCardFormat.PORTRAIT +} + +@Composable +fun PortraitMediaCard( + item: BaseItem, + availableWidth: Dp, + showSecondaryMetadata: Boolean, + onFocused: () -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val width = (availableWidth / 6.8f).coerceIn(116.dp, 184.dp) + MediaCard( + item, width, 2f / 3f, preferPrimary = true, showProgress = false, + showSecondaryMetadata, onFocused, onClick, onLongClick, modifier, + ) +} + +@Composable +fun LandscapeMediaCard( + item: BaseItem, + availableWidth: Dp, + showSecondaryMetadata: Boolean, + onFocused: () -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val width = (availableWidth / 4.25f).coerceIn(184.dp, 316.dp) + MediaCard( + item, width, 16f / 9f, preferPrimary = false, showProgress = false, + showSecondaryMetadata, onFocused, onClick, onLongClick, modifier, + ) +} + +@Composable +fun ContinueWatchingCard( + item: BaseItem, + availableWidth: Dp, + showSecondaryMetadata: Boolean, + onFocused: () -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val width = (availableWidth / 4.25f).coerceIn(184.dp, 316.dp) + MediaCard( + item, width, 16f / 9f, preferPrimary = false, showProgress = true, + showSecondaryMetadata, onFocused, onClick, onLongClick, modifier, + ) +} + +@Composable +private fun MediaCard( + item: BaseItem, + width: Dp, + aspectRatio: Float, + preferPrimary: Boolean, + showProgress: Boolean, + showSecondaryMetadata: Boolean, + onFocused: () -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier, +) { + val repo = ServiceLocator.repository + val density = LocalDensity.current + val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720) + val heightPx = (widthPx / aspectRatio).toInt().coerceAtLeast(1) + val image = remember(item.id, widthPx, preferPrimary) { + if (preferPrimary) { + repo.primaryUrl(item, widthPx)?.let { it to ContentScale.Fit } + ?: repo.backdropUrl(item, widthPx)?.let { it to ContentScale.Crop } + } else { + repo.backdropUrl(item, widthPx)?.let { it to ContentScale.Crop } + ?: repo.primaryUrl(item, widthPx)?.let { it to ContentScale.Fit } + } + } + val imageUrl = image?.first + val position = item.userData?.playbackPositionTicks ?: 0L + val runtime = item.runTimeTicks ?: 0L + val progress = if (runtime > 0L) (position.toFloat() / runtime).coerceIn(0f, 1f) else 0f + var failed by remember(item.id, imageUrl) { mutableStateOf(false) } + var loading by remember(item.id, imageUrl) { mutableStateOf(imageUrl != null) } + val context = LocalContext.current + val imageRequest = remember(imageUrl, widthPx, heightPx, context) { + imageUrl?.let { + ImageRequest.Builder(context) + .data(it) + .size(widthPx, heightPx) + .allowHardware(true) + .crossfade(false) + .build() + } + } + FocusScaleContainer( + onFocused = onFocused, + onClick = onClick, + onLongClick = onLongClick, + contentDescription = cardDescription(item, progress), + modifier = modifier.width(width), + ) { focused -> + Column { + Box( + Modifier + .width(width) + .aspectRatio(aspectRatio) + .then( + if (focused) Modifier.shadow(7.dp, RoundedCornerShape(9.dp)) + else Modifier, + ) + .clip(RoundedCornerShape(9.dp)) + .background(Color(0xFF20252A)) + .border( + 2.dp, + if (focused) Color.White else Color.White.copy(alpha = 0.07f), + RoundedCornerShape(9.dp), + ), + contentAlignment = Alignment.Center, + ) { + if (loading) { + ArtworkLoadingSkeleton(Modifier.fillMaxSize()) + } + if (imageRequest != null) { + AsyncImage( + model = imageRequest, + contentDescription = null, + contentScale = image?.second ?: ContentScale.Crop, + onLoading = { loading = true }, + onSuccess = { + failed = false + loading = false + }, + onError = { + failed = true + loading = false + }, + modifier = Modifier.fillMaxSize(), + ) + } + if (imageUrl == null || failed) { + Icon( + Icons.Default.BrokenImage, + contentDescription = "Artwork unavailable", + tint = QuietText, + modifier = Modifier.size(30.dp), + ) + } + if (showProgress && progress > 0f) { + Box( + Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(5.dp) + .background(Color.Black.copy(alpha = 0.65f)), + ) { + Box(Modifier.fillMaxWidth(progress).height(5.dp).background(EmbyGreen)) + } + } + if (item.userData?.played == true || item.isFavorite) { + Column( + modifier = Modifier.align(Alignment.TopEnd).padding(8.dp), + verticalArrangement = Arrangement.spacedBy(5.dp), + ) { + if (item.userData?.played == true) { + Icon( + Icons.Default.CheckCircle, + contentDescription = "Watched", + tint = EmbyGreen, + modifier = Modifier.size(19.dp), + ) + } + if (item.isFavorite) { + Icon( + Icons.Default.Favorite, + contentDescription = "Favourite", + tint = EmbyGreen, + modifier = Modifier.size(19.dp), + ) + } + } + } + } + Text( + item.name, + color = if (focused) Color.White else Color(0xFFD1D5D8), + fontSize = 14.sp, + fontWeight = if (focused) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 7.dp).fillMaxWidth(), + ) + if (showSecondaryMetadata) { + Text( + cardSubtitle(item, showProgress, position), + color = QuietText, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp).fillMaxWidth(), + ) + } else { + Spacer(Modifier.height(3.dp)) + } + } + } +} + +@Composable +fun FocusScaleContainer( + onFocused: () -> Unit, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + contentDescription: String, + modifier: Modifier = Modifier, + content: @Composable BoxScope.(focused: Boolean) -> Unit, +) { + var focused by remember { mutableStateOf(false) } + var remoteLongPressHandled by remember { mutableStateOf(false) } + val scale by animateFloatAsState( + targetValue = if (focused) 1.025f else 1f, + animationSpec = tween(95), + label = "media-card-focus", + ) + Box( + modifier = modifier + .zIndex(if (focused) 1f else 0f) + .then( + if (focused || scale != 1f) { + Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + } + } else { + Modifier + }, + ) + .onFocusChanged { + focused = it.isFocused + if (it.isFocused) onFocused() + } + .then( + if (onLongClick != null) { + Modifier + .onPreviewKeyEvent { event -> + val native = event.nativeKeyEvent + val activationKey = native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER || + native.keyCode == android.view.KeyEvent.KEYCODE_ENTER || + native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER + when { + activationKey && + native.action == android.view.KeyEvent.ACTION_DOWN && + native.repeatCount > 0 && + !remoteLongPressHandled -> { + remoteLongPressHandled = true + onLongClick() + true + } + activationKey && + native.action == android.view.KeyEvent.ACTION_UP && + remoteLongPressHandled -> { + remoteLongPressHandled = false + true + } + else -> false + } + } + .clickable(onClick = onClick) + } else { + Modifier.clickable(onClick = onClick) + }, + ) + .semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + if (onLongClick != null) { + onLongClick("Quick actions") { + onLongClick() + true + } + } + }, + content = { content(focused) }, + ) +} + +@Composable +fun TvLoadingPlaceholder( + portrait: Boolean, + availableWidth: Dp, + modifier: Modifier = Modifier, +) { + val width = if (portrait) { + (availableWidth / 6.8f).coerceIn(116.dp, 184.dp) + } else { + (availableWidth / 4.25f).coerceIn(184.dp, 316.dp) + } + Column(modifier.width(width)) { + Box( + Modifier + .width(width) + .aspectRatio(if (portrait) 2f / 3f else 16f / 9f) + .clip(RoundedCornerShape(9.dp)), + ) { + ArtworkLoadingSkeleton(Modifier.fillMaxSize()) + } + Spacer(Modifier.height(8.dp)) + SkeletonBlock(width * 0.72f, 11.dp) + Spacer(Modifier.height(5.dp)) + SkeletonBlock(width * 0.48f, 8.dp) + } +} + +@Composable +private fun ArtworkLoadingSkeleton(modifier: Modifier = Modifier) { + Box( + modifier.background( + Brush.linearGradient( + colors = listOf( + Color(0xFF20262B), + Color(0xFF343B41), + Color(0xFF20262B), + ), + ), + ), + ) +} + +private const val BACKDROP_SETTLE_DELAY_MS = 240L + +private fun cardSubtitle(item: BaseItem, showProgress: Boolean, positionTicks: Long): String = when { + showProgress && positionTicks > 0L -> "Resume at ${formatTvPosition(positionTicks)}" + item.isEpisode -> item.seriesName ?: "Up next" + item.productionYear != null && item.runtimeMinutes != null -> + "${item.productionYear} • ${formatTvRuntime(requireNotNull(item.runtimeMinutes))}" + item.productionYear != null -> item.productionYear.toString() + item.isSeries -> "Series" + else -> item.type +} + +private fun cardDescription(item: BaseItem, progress: Float): String = buildString { + append(item.name) + item.seriesName?.let { append(", ").append(it) } + if (progress > 0f) append(", ${(progress * 100).toInt()} percent watched") + if (item.userData?.played == true) append(", watched") + if (item.isFavorite) append(", favourite") +} + +private fun formatTvRuntime(minutes: Int): String { + val hours = minutes / 60 + val remainder = minutes % 60 + return when { + hours > 0 && remainder > 0 -> "${hours}h ${remainder}m" + hours > 0 -> "${hours}h" + else -> "${remainder}m" + } +} + +private fun formatTvPosition(ticks: Long): String { + val minutes = (ticks / 600_000_000L).toInt().coerceAtLeast(0) + return formatTvRuntime(minutes) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt new file mode 100644 index 0000000..b024f48 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -0,0 +1,366 @@ +package com.ponzischeme89.memby.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.ponzischeme89.memby.data.EmbyRepository +import com.ponzischeme89.memby.data.HomeCache +import com.ponzischeme89.memby.data.analytics.RowAnalytics +import com.ponzischeme89.memby.data.friendlyEmbyError +import com.ponzischeme89.memby.data.isMaintenanceError +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.HomeRow +import com.ponzischeme89.memby.data.model.UserItemData +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +enum class HomeSection { CONTINUE, NEXT_UP, FAVORITES, LATEST } + +data class HomeUiState( + val continueWatching: List = emptyList(), + val nextUp: List = emptyList(), + val favorites: List = emptyList(), + val latestMovies: List = emptyList(), + /** + * Rows as composed by the gateway, including recommendation strips. Empty on the + * direct-to-Emby path, where the client composes rows itself. + */ + val rows: List = emptyList(), + val loading: Set = HomeSection.entries.toSet(), + val hasRefreshError: Boolean = false, + /** + * A message worth showing above the rows. Null means "use the generic + * slow-connection wording". + */ + val statusMessage: String? = null, + /** + * Set when the gateway reports a deliberate outage. Distinct from [statusMessage] + * because this replaces the whole content area rather than adding a banner — the + * rows behind it would be stale and unusable anyway. + */ + val maintenanceMessage: String? = null, +) { + val watchingAndNextUp: List + get() = (continueWatching + nextUp).distinctBy(BaseItem::id) + + fun toCache() = HomeCache( + continueWatching = continueWatching, + nextUp = nextUp, + favorites = favorites, + latestMovies = latestMovies, + rows = rows, + ) + + companion object { + fun from(cache: HomeCache?) = HomeUiState( + continueWatching = cache?.continueWatching.orEmpty(), + nextUp = cache?.nextUp.orEmpty(), + favorites = cache?.favorites.orEmpty(), + latestMovies = cache?.latestMovies.orEmpty(), + rows = cache?.rows.orEmpty(), + loading = buildSet { + if (cache?.continueWatching.isNullOrEmpty()) add(HomeSection.CONTINUE) + if (cache?.nextUp.isNullOrEmpty()) add(HomeSection.NEXT_UP) + if (cache?.favorites.isNullOrEmpty()) add(HomeSection.FAVORITES) + if (cache?.latestMovies.isNullOrEmpty()) add(HomeSection.LATEST) + }, + ) + } +} + +class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { + private val refreshMutex = Mutex() + private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome())) + val state: StateFlow = _state.asStateFlow() + private val _focusedItem = MutableStateFlow(initialFocusedItem(_state.value)) + val focusedItem: StateFlow = _focusedItem.asStateFlow() + private var metadataJob: Job? = null + private val metadataCache = object : LinkedHashMap(32, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 32 + } + + /** Row engagement, buffered here and uploaded in batches. */ + private val analytics = RowAnalytics() + + init { + refreshAll() + viewModelScope.launch { + repository.playbackStops.collect { refreshWatching() } + } + viewModelScope.launch { + // A D-pad produces focus changes far faster than anything should produce + // HTTP requests, so engagement is uploaded on a slow drumbeat instead. + while (true) { + delay(ANALYTICS_FLUSH_INTERVAL_MS) + flushAnalytics() + } + } + } + + fun trackRowImpression(rowId: String, rowKind: String) = analytics.rowImpression(rowId, rowKind) + + fun trackRowFocused(rowId: String, rowKind: String, itemId: String) = + analytics.rowFocused(rowId, rowKind, itemId) + + fun trackRowSelected(rowId: String, rowKind: String, itemId: String) = + analytics.rowSelected(rowId, rowKind, itemId) + + /** + * Closes the open dwell measurement and uploads. Called on a timer and when the home + * screen stops, so time spent sitting on one row is not lost. + */ + fun flushAnalytics() { + analytics.endFocus() + repository.reportRowEvents(analytics.drain()) + } + + fun refreshAll() { + viewModelScope.launch(Dispatchers.IO) { + refreshMutex.withLock { + _state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) } + if (repository.supportsBatchHome) { + loadBatchHome() + } else { + coroutineScope { + launch { loadContinueWatching() } + launch { loadNextUp() } + launch { loadFavorites() } + launch { loadLatest() } + } + } + persistCurrentHome() + } + } + } + + /** + * The gateway returns every row in one response, so the four-way fan-out collapses + * into a single request and the rows can no longer arrive out of step with each other. + */ + private suspend fun loadBatchHome() { + runCatching { repository.getHome() } + .onSuccess { home -> + _state.update { current -> + current.copy( + continueWatching = home.continueWatching, + nextUp = home.nextUp, + favorites = home.favorites, + latestMovies = home.latestMovies, + // Recommendation rows are built in the background by the gateway, + // so an early response can arrive without them. Keeping the rows + // we already had stops the strip flickering out and back in. + rows = home.rows.ifEmpty { current.rows }, + loading = emptySet(), + hasRefreshError = home.partial, + statusMessage = null, + // A successful response is the only thing that clears the + // maintenance screen, so a retry that fails keeps it up. + maintenanceMessage = null, + ) + } + if (_focusedItem.value == null) { + initialFocusedItem(_state.value)?.let(::focusItem) + } + } + .onFailure { error -> + val maintenance = isMaintenanceError(error) + _state.update { + it.copy( + loading = emptySet(), + hasRefreshError = true, + statusMessage = null, + maintenanceMessage = if (maintenance) friendlyEmbyError(error) else null, + ) + } + } + } + + /** + * Updates local metadata immediately, then enriches it only after focus settles. + * Cancelling the previous job prevents stale responses from winning rapid D-pad navigation. + */ + fun focusItem(item: BaseItem) { + val cached = synchronized(metadataCache) { metadataCache[item.id] } + _focusedItem.value = cached ?: item + metadataJob?.cancel() + if (cached != null) return + metadataJob = viewModelScope.launch(Dispatchers.IO) { + delay(FOCUS_METADATA_DEBOUNCE_MS) + val details = runCatching { repository.getItemDetails(item.id) }.getOrNull() ?: return@launch + synchronized(metadataCache) { metadataCache[item.id] = details } + if (_focusedItem.value?.id == item.id) { + _focusedItem.value = details + } + } + } + + fun setFavorite(item: BaseItem, favorite: Boolean) { + updateFavorite(item, favorite) + viewModelScope.launch(Dispatchers.IO) { + runCatching { repository.setFavorite(item.id, favorite) } + .onSuccess { confirmed -> + updateFavorite(item, confirmed) + } + .onFailure { + updateFavorite(item, !favorite) + } + } + } + + private fun updateFavorite(item: BaseItem, favorite: Boolean) { + updateUserData(item.id) { it.copy(isFavorite = favorite) } + _state.update { state -> + val updatedItem = item.copy( + userData = (item.userData ?: UserItemData()).copy(isFavorite = favorite), + ) + state.copy( + favorites = if (favorite) { + (state.favorites + updatedItem).distinctBy(BaseItem::id) + } else { + state.favorites.filterNot { it.id == item.id } + }, + ) + } + } + + fun setPlayed(item: BaseItem, played: Boolean) { + updateUserData(item.id) { + it.copy( + played = played, + playbackPositionTicks = if (played) 0L else it.playbackPositionTicks, + ) + } + viewModelScope.launch(Dispatchers.IO) { + runCatching { repository.setPlayed(item.id, played) } + .onSuccess { confirmed -> + updateUserData(item.id) { it.copy(played = confirmed) } + } + .onFailure { + updateUserData(item.id) { it.copy(played = !played) } + } + } + } + + private fun updateUserData(itemId: String, transform: (UserItemData) -> UserItemData) { + fun BaseItem.updated(): BaseItem = + if (id == itemId) copy(userData = transform(userData ?: UserItemData())) else this + + _state.update { + it.copy( + continueWatching = it.continueWatching.map(BaseItem::updated), + nextUp = it.nextUp.map(BaseItem::updated), + favorites = it.favorites.map(BaseItem::updated), + latestMovies = it.latestMovies.map(BaseItem::updated), + // Server rows hold their own copies of the same items, so an optimistic + // favourite/watched toggle has to reach into them too or the heart on a + // recommendation card would not light up. + rows = it.rows.map { row -> row.copy(items = row.items.map(BaseItem::updated)) }, + ) + } + _focusedItem.update { it?.updated() } + synchronized(metadataCache) { + metadataCache[itemId]?.let { metadataCache[itemId] = it.updated() } + } + } + + private suspend fun refreshWatching() { + refreshMutex.withLock { + _state.update { it.copy(loading = it.loading + setOf(HomeSection.CONTINUE, HomeSection.NEXT_UP)) } + if (repository.supportsBatchHome) { + // One request is cheaper than two here as well, and playback just + // invalidated this user's rows on the gateway anyway. + loadBatchHome() + } else { + coroutineScope { + launch { loadContinueWatching(clearLoading = false) } + launch { loadNextUp(clearLoading = false) } + } + } + _state.update { it.copy(loading = it.loading - setOf(HomeSection.CONTINUE, HomeSection.NEXT_UP)) } + persistCurrentHome() + } + } + + private suspend fun loadContinueWatching(clearLoading: Boolean = true) = + load(HomeSection.CONTINUE, clearLoading, { repository.getContinueWatching() }) { state, items -> + state.copy(continueWatching = items) + } + + private suspend fun loadNextUp(clearLoading: Boolean = true) = + load(HomeSection.NEXT_UP, clearLoading, { repository.getNextUp() }) { state, items -> + state.copy(nextUp = items) + } + + private suspend fun loadFavorites() = + load(HomeSection.FAVORITES, true, { repository.getFavorites() }) { state, items -> + state.copy(favorites = items) + } + + private suspend fun loadLatest() = + load(HomeSection.LATEST, true, { repository.getLatestMovies() }) { state, items -> + state.copy(latestMovies = items) + } + + private suspend fun load( + section: HomeSection, + clearLoading: Boolean, + request: suspend () -> List, + updateItems: (HomeUiState, List) -> HomeUiState, + ) { + runCatching { request() } + .onSuccess { items -> + _state.update { current -> + updateItems(current, items).let { + if (clearLoading) it.copy(loading = it.loading - section) else it + } + } + if (_focusedItem.value == null) { + items.firstOrNull()?.let(::focusItem) + } + } + .onFailure { + _state.update { current -> + current.copy( + loading = if (clearLoading) current.loading - section else current.loading, + hasRefreshError = true, + ) + } + } + } + + private suspend fun persistCurrentHome() { + runCatching { repository.cacheHome(_state.value.toCache()) } + } + + override fun onCleared() { + flushAnalytics() + super.onCleared() + } + + companion object { + private const val FOCUS_METADATA_DEBOUNCE_MS = 140L + private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L + + private fun initialFocusedItem(state: HomeUiState): BaseItem? = + state.watchingAndNextUp.firstOrNull() + ?: state.latestMovies.firstOrNull() + ?: state.favorites.firstOrNull() + } +} + +class HomeViewModelFactory(private val repository: EmbyRepository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + require(modelClass.isAssignableFrom(HomeViewModel::class.java)) + return HomeViewModel(repository) as T + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt new file mode 100644 index 0000000..fa22782 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -0,0 +1,1419 @@ +package com.ponzischeme89.memby.ui + +import android.content.Intent +import android.os.Bundle +import android.provider.Settings as AndroidSettings +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.key +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.animateColorAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import com.ponzischeme89.memby.R +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.Settings +import com.ponzischeme89.memby.data.EmbyProfile +import com.ponzischeme89.memby.data.ServerConfig +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.ui.player.PlayerActivity +import com.ponzischeme89.memby.performance.PerformanceMonitor +import com.ponzischeme89.memby.ui.settings.SettingsSheet +import com.ponzischeme89.memby.ui.screensaver.ScreensaverActivity +import com.ponzischeme89.memby.ui.theme.MembyTheme +import com.ponzischeme89.memby.update.UpdateChecker +import com.ponzischeme89.memby.update.UpdateStatus +import androidx.tv.material3.Button +import androidx.tv.material3.Card +import androidx.tv.material3.Text +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent.getBooleanExtra(EXTRA_LAUNCH_UPDATED_SLIDESHOW, false)) { + startActivity(ScreensaverActivity.restartAfterUpdateIntent(this)) + finish() + return + } + setContent { + MembyTheme { AppRoot(onCloseSettings = ::finish) } + } + PerformanceMonitor.start(this) + } + + companion object { + const val EXTRA_LAUNCH_UPDATED_SLIDESHOW = "com.ponzischeme89.memby.extra.LAUNCH_UPDATED_SLIDESHOW" + } +} + +@Composable +private fun AppRoot(onCloseSettings: () -> Unit) { + val repo = ServiceLocator.repository + var settings by remember { mutableStateOf(null) } + LaunchedEffect(repo) { + repo.settingsFlow.collect { settings = it } + } + + Box(Modifier.fillMaxSize().background(Color(0xFF0B0E11))) { + val loaded = settings + when { + loaded == null -> MembyLoadingScreen() + loaded.isSignedIn -> { + BackHandler(onBack = onCloseSettings) + key(loaded.userId, loaded.serverUrl) { + HomeScreen(loaded) + } + } + loaded.profiles.isNotEmpty() -> ProfileEntryScreen(loaded) + else -> SetupScreen() + } + } +} + +@Composable +private fun MembyLoadingScreen() { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + androidx.compose.foundation.Image( + painter = androidx.compose.ui.res.painterResource(com.ponzischeme89.memby.R.drawable.emby_logo), + contentDescription = "Emby", + modifier = Modifier.width(92.dp).height(76.dp), + ) + Spacer(Modifier.height(22.dp)) + Text("Emby is loading", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(10.dp)) + Text("Preparing your library…", color = Color(0xFF9EA6AD), fontSize = 16.sp) + } +} + +@Composable +private fun SetupScreen( + onCancel: (() -> Unit)? = null, +) { + val repo = ServiceLocator.repository + val scope = rememberCoroutineScope() + + // A build that hardwires the server (memby.serverUrl) never asks for an address. + val hardwiredHost = ServerConfig.displayHost + var serverUrl by rememberSaveable { mutableStateOf(if (hardwiredHost != null) "" else "http://") } + var username by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var connecting by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + val serverFocus = remember { FocusRequester() } + val usernameFocus = remember { FocusRequester() } + LaunchedEffect(hardwiredHost) { + runCatching { if (hardwiredHost != null) usernameFocus.requestFocus() else serverFocus.requestFocus() } + } + if (onCancel != null) BackHandler(onBack = onCancel) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 96.dp, vertical = 56.dp) + .width(720.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text("Connect to Memby", color = Color.White, fontSize = 40.sp, fontWeight = FontWeight.Bold) + Text( + if (hardwiredHost != null) { + "Sign in with your Emby username and password." + } else { + "Enter your Emby server address and sign in." + }, + color = Color(0xFFB9C0C7), + fontSize = 18.sp, + ) + + if (hardwiredHost != null) { + Text("Server: $hardwiredHost", color = Color(0xFF9AA3AC), fontSize = 15.sp) + } else { + TvTextField( + label = "Server address (e.g. http://192.168.1.10:8096)", + value = serverUrl, + onValueChange = { serverUrl = it; error = null }, + modifier = Modifier.focusRequester(serverFocus), + keyboardType = KeyboardType.Uri, + ) + } + TvTextField( + label = "Username", + value = username, + onValueChange = { username = it; error = null }, + modifier = Modifier.focusRequester(usernameFocus), + ) + TvTextField( + label = "Password", + value = password, + onValueChange = { password = it; error = null }, + isPassword = true, + ) + + error?.let { Text(it, color = Color(0xFFFF6B6B), fontSize = 16.sp) } + + Button( + onClick = { + if (hardwiredHost == null && serverUrl.isBlank()) { + error = "Server address is required." + return@Button + } + if (username.isBlank()) { + error = "Username is required." + return@Button + } + connecting = true + error = null + scope.launch { + runCatching { repo.authenticate(serverUrl, username, password) } + .onFailure { error = "Sign-in failed: ${it.message}" } + connecting = false + } + }, + ) { + Text(if (connecting) "Connecting…" else "Connect") + } + if (onCancel != null) { + Button(onClick = onCancel) { Text("Cancel") } + } + } +} + +@Composable +private fun ProfileEntryScreen(settings: Settings) { + val repo = ServiceLocator.repository + val scope = rememberCoroutineScope() + var addingProfile by remember { mutableStateOf(false) } + var switchingProfileId by remember { mutableStateOf(null) } + + if (addingProfile) { + SetupScreen(onCancel = { addingProfile = false }) + } else { + ProfileChooser( + profiles = settings.profiles, + currentProfileId = null, + switchingProfileId = switchingProfileId, + onSelect = { profile -> + switchingProfileId = profile.id + scope.launch { + runCatching { repo.switchProfile(profile) } + .onFailure { switchingProfileId = null } + } + }, + onAddProfile = { addingProfile = true }, + onClose = null, + ) + } +} + +@Composable +private fun ProfileChooser( + profiles: List, + currentProfileId: String?, + switchingProfileId: String?, + onSelect: (EmbyProfile) -> Unit, + onAddProfile: () -> Unit, + onClose: (() -> Unit)?, +) { + val firstFocus = remember { FocusRequester() } + val orderedProfiles = remember(profiles, currentProfileId) { + profiles.sortedByDescending { it.id == currentProfileId } + } + LaunchedEffect(orderedProfiles.firstOrNull()?.id) { + firstFocus.requestFocus() + } + Column( + modifier = Modifier + .fillMaxSize() + .background(Color(0xFF090B0D)) + .padding(horizontal = 72.dp, vertical = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + androidx.compose.foundation.Image( + painter = androidx.compose.ui.res.painterResource(com.ponzischeme89.memby.R.drawable.emby_logo), + contentDescription = "Emby", + modifier = Modifier.width(72.dp).height(60.dp), + ) + Spacer(Modifier.height(20.dp)) + Text("Who’s watching Emby?", color = Color.White, fontSize = 36.sp, fontWeight = FontWeight.SemiBold) + Text( + if (switchingProfileId == null) "Choose a profile to continue" else "Switching profile…", + color = Color(0xFFAAB1B7), + fontSize = 17.sp, + modifier = Modifier.padding(top = 8.dp, bottom = 30.dp), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.Top, + ) { + orderedProfiles.forEachIndexed { index, profile -> + ProfileTile( + name = profile.username, + current = profile.id == currentProfileId, + enabled = switchingProfileId == null, + onClick = { onSelect(profile) }, + modifier = if (index == 0) Modifier.focusRequester(firstFocus) else Modifier, + ) + } + ProfileTile( + name = "Add someone else", + current = false, + enabled = switchingProfileId == null, + symbol = "+", + onClick = onAddProfile, + modifier = if (orderedProfiles.isEmpty()) Modifier.focusRequester(firstFocus) else Modifier, + ) + } + if (onClose != null) { + Spacer(Modifier.height(30.dp)) + Button(onClick = onClose, enabled = switchingProfileId == null) { Text("Back to Emby") } + } + } +} + +@Composable +private fun ProfileTile( + name: String, + current: Boolean, + enabled: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + symbol: String = name.trim().firstOrNull()?.uppercase() ?: "?", +) { + FocusScaleContainer( + onFocused = {}, + onClick = { if (enabled) onClick() }, + contentDescription = if (current) "$name, current profile" else name, + modifier = modifier.width(154.dp), + ) { focused -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(112.dp) + .clip(CircleShape) + .background(if (focused) Color(0xFF5BC653) else Color(0xFF30373D)) + .border( + width = if (current) 4.dp else if (focused) 3.dp else 1.dp, + color = if (current) Color(0xFF52B54B) else if (focused) Color.White else Color(0xFF5B646C), + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text(symbol, color = Color.White, fontSize = 38.sp, fontWeight = FontWeight.Bold) + } + Text( + name, + color = if (focused) Color.White else Color(0xFFD0D4D7), + fontSize = 16.sp, + fontWeight = if (focused || current) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 12.dp), + ) + if (current) { + Text("Current", color = Color(0xFF73D56C), fontSize = 13.sp, modifier = Modifier.padding(top = 3.dp)) + } + } + } +} + +@Composable +private fun HomeScreen(settings: Settings) { + val repo = ServiceLocator.repository + val context = LocalContext.current + val scope = rememberCoroutineScope() + val factory = remember(repo) { HomeViewModelFactory(repo) } + val homeViewModel: HomeViewModel = viewModel(factory = factory) + val homeState by homeViewModel.state.collectAsStateWithLifecycle() + + var showSettings by remember { mutableStateOf(false) } + var showProfiles by remember { mutableStateOf(false) } + var addProfile by remember { mutableStateOf(false) } + var switchingProfileId by remember { mutableStateOf(null) } + var selectedDestination by rememberSaveable { mutableStateOf(BrowseDestination.HOME) } + var navigationExpanded by remember { mutableStateOf(false) } + var detailsItem by remember { mutableStateOf(null) } + var quickMenuItem by remember { mutableStateOf(null) } + var returnRowId by rememberSaveable { mutableStateOf(null) } + var returnItemId by rememberSaveable { mutableStateOf(null) } + val navigationFocusRequester = remember { FocusRequester() } + val contentFocusRequester = remember { FocusRequester() } + val cardReturnFocusRequester = remember { FocusRequester() } + var initialFocusRequested by remember { mutableStateOf(false) } + val playItem: (BaseItem) -> Unit = { item -> + scope.launch { + runCatching { repo.resolvePlayable(item) }.onSuccess { playable -> + context.startActivity( + PlayerActivity.intent( + context = context, + itemId = playable.itemId, + url = playable.url, + title = playable.title, + resumePositionMs = playable.resumePositionMs, + ), + ) + } + } + } + + val rows = remember(homeState, selectedDestination, settings.homeSections, settings.showHomeCardMetadata) { + homeRowsFor(selectedDestination, homeState, settings) + } + LaunchedEffect(selectedDestination, rows.firstOrNull()?.items?.firstOrNull()?.id) { + val firstItem = rows.firstNotNullOfOrNull { it.items.firstOrNull() } + firstItem?.let(homeViewModel::focusItem) + if (firstItem != null && !initialFocusRequested) { + kotlinx.coroutines.delay(16L) + contentFocusRequester.requestFocus() + initialFocusRequested = true + } + } + + // Engagement is uploaded on a timer, but a viewer who opens the player after sitting + // on one row for a minute would otherwise lose that minute. Flushing on ON_STOP (and + // on dispose) closes the open dwell measurement while it still means something. + val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, homeViewModel) { + val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> + if (event == androidx.lifecycle.Lifecycle.Event.ON_STOP) homeViewModel.flushAnalytics() + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + homeViewModel.flushAnalytics() + } + } + + val contentShift by animateDpAsState( + targetValue = if (navigationExpanded) TvRailContentShift else 0.dp, + animationSpec = tween(150), + label = "navigation-content-shift", + ) + Box(Modifier.fillMaxSize().background(Color(0xFF090B0D))) { + Row(Modifier.fillMaxSize()) { + TvNavigationRail( + selected = selectedDestination, + expanded = navigationExpanded, + navigationFocusRequester = navigationFocusRequester, + contentFocusRequester = contentFocusRequester, + onRailFocusChanged = { navigationExpanded = it }, + onDestinationSelected = { destination -> + when (destination) { + BrowseDestination.SETTINGS -> showSettings = true + BrowseDestination.PROFILES -> showProfiles = true + else -> selectedDestination = destination + } + }, + ) + androidx.compose.foundation.layout.BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxSize() + .offset(x = contentShift), + ) { + val maintenanceMessage = homeState.maintenanceMessage + if (maintenanceMessage != null) { + // The rows are gone but the rail is not: Settings and Switch user are + // local, so there is no reason to strand the viewer here. + MaintenanceScreen( + message = maintenanceMessage, + contentFocusRequester = contentFocusRequester, + navigationFocusRequester = navigationFocusRequester, + onRetry = homeViewModel::refreshAll, + ) + LaunchedEffect(Unit) { + kotlinx.coroutines.delay(450L) + runCatching { contentFocusRequester.requestFocus() } + } + return@BoxWithConstraints + } + + FocusedHomeBackdrop(homeViewModel) + val metadataHeight = (maxHeight * 0.42f).coerceIn(220.dp, 300.dp) + val contentWidth = maxWidth + HomeArtworkPreloader(rows = rows, availableWidth = contentWidth) + val verticalState = rememberSaveable( + selectedDestination.name, + saver = LazyListState.Saver, + ) { LazyListState() } + Column(Modifier.fillMaxSize()) { + FocusedHomeMetadata( + homeViewModel = homeViewModel, + sectionLabel = selectedDestination.label, + contentFocusRequester = contentFocusRequester, + navigationFocusRequester = navigationFocusRequester, + onPlay = playItem, + onContentFocused = { navigationExpanded = false }, + modifier = Modifier + .height(metadataHeight) + .padding(start = 36.dp, end = 36.dp, top = 24.dp, bottom = 10.dp), + ) + androidx.compose.animation.AnimatedVisibility( + visible = homeState.hasRefreshError, + enter = androidx.compose.animation.fadeIn(tween(180)), + exit = androidx.compose.animation.fadeOut(tween(120)), + ) { + Text( + // The gateway's maintenance notice when it sent one, and the + // generic wording otherwise. + homeState.statusMessage ?: "Connection is slow — showing available content", + color = Color(0xFFD1D5D8), + fontSize = 14.sp, + modifier = Modifier.padding(horizontal = 36.dp, vertical = 4.dp), + ) + } + LazyColumn( + state = verticalState, + modifier = Modifier.weight(1f).fillMaxWidth(), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + items( + items = rows, + key = { it.id }, + contentType = { "media-row-${it.kind}" }, + ) { row -> + // LazyColumn composes a row as it scrolls into view, which is + // as close to "the viewer saw it" as the TV can observe. + LaunchedEffect(row.id) { + homeViewModel.trackRowImpression(row.id, row.kind.name) + } + MediaRow( + row = row, + availableWidth = contentWidth, + navigationFocusRequester = navigationFocusRequester, + contentEntryFocusRequester = null, + returnFocusItemId = returnItemId.takeIf { returnRowId == row.id }, + returnFocusRequester = cardReturnFocusRequester, + onContentFocused = { navigationExpanded = false }, + onItemFocused = { item -> + homeViewModel.focusItem(item) + homeViewModel.trackRowFocused(row.id, row.kind.name, item.id) + }, + onItemSelected = { item -> + returnRowId = row.id + returnItemId = item.id + homeViewModel.focusItem(item) + homeViewModel.trackRowSelected(row.id, row.kind.name, item.id) + detailsItem = item + }, + onItemLongPressed = { item -> + returnRowId = row.id + returnItemId = item.id + homeViewModel.focusItem(item) + quickMenuItem = item + }, + ) + } + } + } + } + } + if (showSettings) { + BackHandler(onBack = { showSettings = false }) + SettingsSheet(editableServer = true, onClose = { showSettings = false }) + } + if (showProfiles) { + BackHandler(onBack = { showProfiles = false }) + ProfileChooser( + profiles = settings.profiles, + currentProfileId = settings.activeProfileId, + switchingProfileId = switchingProfileId, + onSelect = { profile -> + if (profile.id == settings.activeProfileId) { + showProfiles = false + } else { + switchingProfileId = profile.id + scope.launch { + runCatching { repo.switchProfile(profile) } + .onFailure { switchingProfileId = null } + } + } + }, + onAddProfile = { + showProfiles = false + addProfile = true + }, + onClose = { showProfiles = false }, + ) + } + if (addProfile) { + SetupScreen(onCancel = { addProfile = false }) + } + detailsItem?.let { selected -> + BackHandler { + detailsItem = null + cardReturnFocusRequester.requestFocus() + } + FocusedDetailsOverlay( + homeViewModel = homeViewModel, + selected = selected, + onPlay = { + detailsItem = null + playItem(it) + }, + onToggleFavorite = homeViewModel::setFavorite, + onTogglePlayed = homeViewModel::setPlayed, + onClose = { + detailsItem = null + cardReturnFocusRequester.requestFocus() + }, + ) + } + quickMenuItem?.let { selected -> + BackHandler { + quickMenuItem = null + cardReturnFocusRequester.requestFocus() + } + FocusedQuickActionsOverlay( + homeViewModel = homeViewModel, + selected = selected, + onSetFavorite = homeViewModel::setFavorite, + onSetPlayed = homeViewModel::setPlayed, + onClose = { + quickMenuItem = null + cardReturnFocusRequester.requestFocus() + }, + ) + } + } +} + +@Composable +private fun HomeArtworkPreloader( + rows: List, + availableWidth: androidx.compose.ui.unit.Dp, +) { + val context = LocalContext.current + val density = LocalDensity.current + val repo = ServiceLocator.repository + val discovered = remember(rows) { + rows.flatMap { row -> row.items.map { row.kind to it } } + .distinctBy { it.second.id } + .take(14) + } + val signature = remember(discovered) { discovered.joinToString("|") { it.second.id } } + LaunchedEffect(signature, availableWidth) { + // Let visible cards win the first network/decode slots, then warm everything + // else that this home response discovered into Coil's memory and disk caches. + kotlinx.coroutines.delay(350L) + discovered.forEach { (kind, item) -> + val landscape = kind == MediaRowKind.CONTINUE || + kind == MediaRowKind.NEXT_UP || + item.isEpisode + val width = if (landscape) { + (availableWidth / 4.25f).coerceIn(184.dp, 316.dp) + } else { + (availableWidth / 6.8f).coerceIn(116.dp, 184.dp) + } + val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720) + val heightPx = if (landscape) (widthPx * 9f / 16f).toInt() else (widthPx * 3f / 2f).toInt() + val url = if (landscape) { + repo.backdropUrl(item, widthPx) ?: repo.primaryUrl(item, widthPx) + } else { + repo.primaryUrl(item, widthPx) ?: repo.backdropUrl(item, widthPx) + } ?: return@forEach + context.imageLoader.execute( + ImageRequest.Builder(context) + .data(url) + .size(widthPx, heightPx) + .allowHardware(true) + .crossfade(false) + .build(), + ) + } + } +} + +@Composable +private fun FocusedHomeBackdrop(homeViewModel: HomeViewModel) { + val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() + BackdropLayer(item = focusedItem, modifier = Modifier.fillMaxSize()) +} + +@Composable +private fun FocusedHomeMetadata( + homeViewModel: HomeViewModel, + sectionLabel: String, + contentFocusRequester: FocusRequester, + navigationFocusRequester: FocusRequester, + onPlay: (BaseItem) -> Unit, + onContentFocused: () -> Unit, + modifier: Modifier = Modifier, +) { + val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() + val homeState by homeViewModel.state.collectAsStateWithLifecycle() + MediaMetadataPanel( + item = focusedItem, + loading = homeState.loading.isNotEmpty(), + sectionLabel = sectionLabel, + contentFocusRequester = contentFocusRequester, + navigationFocusRequester = navigationFocusRequester, + onPlay = onPlay, + onContentFocused = onContentFocused, + modifier = modifier, + ) +} + +@Composable +private fun FocusedDetailsOverlay( + homeViewModel: HomeViewModel, + selected: BaseItem, + onPlay: (BaseItem) -> Unit, + onToggleFavorite: (BaseItem, Boolean) -> Unit, + onTogglePlayed: (BaseItem, Boolean) -> Unit, + onClose: () -> Unit, +) { + val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() + MediaDetailsOverlay( + item = focusedItem?.takeIf { it.id == selected.id } ?: selected, + onPlay = onPlay, + onToggleFavorite = onToggleFavorite, + onTogglePlayed = onTogglePlayed, + onClose = onClose, + ) +} + +@Composable +private fun FocusedQuickActionsOverlay( + homeViewModel: HomeViewModel, + selected: BaseItem, + onSetFavorite: (BaseItem, Boolean) -> Unit, + onSetPlayed: (BaseItem, Boolean) -> Unit, + onClose: () -> Unit, +) { + val focusedItem by homeViewModel.focusedItem.collectAsStateWithLifecycle() + MediaQuickActionsOverlay( + item = focusedItem?.takeIf { it.id == selected.id } ?: selected, + onSetFavorite = onSetFavorite, + onSetPlayed = onSetPlayed, + onClose = onClose, + ) +} + +/** + * Maps the gateway's rows onto the TV's row model. + * + * The user's section preferences still apply to the four fixed rows — turning off + * "Favourites" must keep working — but anything the server invented (recommendations, + * and whatever it grows next) is always shown, since the user never opted out of a row + * that did not exist when they last opened Settings. + */ +internal fun serverHomeRows(state: HomeUiState, settings: Settings): List { + val enabledSections = settings.homeSections.split(',').map(String::trim).toSet() + val stillLoading = state.loading.isNotEmpty() + + return state.rows + .filter { row -> + when (row.kind) { + "continue", "nextup" -> "continue" in enabledSections + "favorites" -> "favorites" in enabledSections + "latest" -> "latest" in enabledSections + else -> true + } + } + .map { row -> + HomeBrowseRow( + id = row.id, + title = row.title, + items = row.items, + kind = when (row.kind) { + "continue" -> MediaRowKind.CONTINUE + "nextup" -> MediaRowKind.NEXT_UP + "favorites" -> MediaRowKind.FAVORITES + "shows" -> MediaRowKind.SHOWS + // Recommendation strips, "latest", and any kind a future server + // sends get poster cards, which suit mixed movie/series rows. + else -> MediaRowKind.MOVIES + }, + loading = stillLoading && row.items.isEmpty(), + emptyMessage = when (row.kind) { + "continue" -> "Nothing in progress" + "nextup" -> "You're all caught up" + "favorites" -> "Your favourites will appear here" + "latest" -> "No recent movies found" + else -> "Nothing to show here yet" + }, + showSecondaryMetadata = settings.showHomeCardMetadata, + ) + } +} + +private fun homeRowsFor( + destination: BrowseDestination, + state: HomeUiState, + settings: Settings, +): List { + val continueRow = HomeBrowseRow( + id = "continue", + title = "Continue Watching", + items = state.continueWatching, + kind = MediaRowKind.CONTINUE, + loading = HomeSection.CONTINUE in state.loading, + emptyMessage = "Nothing in progress", + showSecondaryMetadata = settings.showHomeCardMetadata, + ) + val nextUpRow = HomeBrowseRow( + id = "next-up", + title = "Next Up", + items = state.nextUp, + kind = MediaRowKind.NEXT_UP, + loading = HomeSection.NEXT_UP in state.loading, + emptyMessage = "You're all caught up", + showSecondaryMetadata = settings.showHomeCardMetadata, + ) + val latestMovies = HomeBrowseRow( + id = "latest-movies", + title = "Recently Added Movies", + items = state.latestMovies, + kind = MediaRowKind.MOVIES, + loading = HomeSection.LATEST in state.loading, + emptyMessage = "No recent movies found", + showSecondaryMetadata = settings.showHomeCardMetadata, + ) + val favorites = HomeBrowseRow( + id = "favorites", + title = "Favourites", + items = state.favorites, + kind = MediaRowKind.FAVORITES, + loading = HomeSection.FAVORITES in state.loading, + emptyMessage = "Your favourites will appear here", + showSecondaryMetadata = settings.showHomeCardMetadata, + ) + return when (destination) { + // The gateway composes the home screen — including rows this app has no concept + // of, like "Because you watched …" — so when it sends rows, they win. + BrowseDestination.HOME -> if (state.rows.isNotEmpty()) { + serverHomeRows(state, settings) + } else { + settings.homeSections + .split(',') + .map(String::trim) + .distinct() + .flatMap { section -> + when (section) { + "continue" -> listOf(continueRow, nextUpRow) + "latest" -> listOf(latestMovies) + "favorites" -> listOf(favorites) + else -> emptyList() + } + } + } + BrowseDestination.MOVIES -> listOf( + latestMovies, + favorites.copy( + id = "favourite-movies", + title = "Favourite Movies", + items = state.favorites.filter(BaseItem::isMovie), + ), + ) + BrowseDestination.SHOWS -> listOf( + nextUpRow, + continueRow.copy( + id = "continue-shows", + items = state.continueWatching.filter(BaseItem::isEpisode), + ), + favorites.copy( + id = "favourite-shows", + title = "Favourite Shows", + items = state.favorites.filter { it.isSeries || it.isEpisode }, + ), + ) + BrowseDestination.FAVORITES -> listOf(favorites) + BrowseDestination.PROFILES -> emptyList() + BrowseDestination.SETTINGS -> emptyList() + } +} + +@Composable +private fun HeaderAction(label: String, onClick: () -> Unit) { + var focused by remember { mutableStateOf(false) } + val textColor by animateColorAsState( + targetValue = if (focused) Color.White else Color(0xFFB8BEC3), + animationSpec = tween(100), + label = "header-action-text", + ) + val backgroundColor by animateColorAsState( + targetValue = if (focused) Color.White.copy(alpha = 0.10f) else Color.Transparent, + animationSpec = tween(100), + label = "header-action-background", + ) + Text( + text = label, + color = textColor, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier + .border( + width = if (focused) 1.dp else 0.dp, + color = if (focused) Color.White else Color.Transparent, + shape = RoundedCornerShape(6.dp), + ) + .background(backgroundColor, RoundedCornerShape(6.dp)) + .onFocusChanged { focused = it.isFocused } + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) +} + +private data class HomeRowData( + val title: String, + val items: List, + val emptyMessage: String, + val loading: Boolean, + val showResume: Boolean = false, +) + +@Composable +private fun HomeHero(item: BaseItem) { + val repo = ServiceLocator.repository + val context = LocalContext.current + val backdrop = repo.backdropUrl(item, maxWidth = 1920) ?: repo.primaryUrl(item, maxWidth = 1200) + Box(Modifier.fillMaxWidth().height(530.dp)) { + backdrop?.let { + AsyncImage( + model = ImageRequest.Builder(context) + .data(it) + .crossfade(220) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + Box( + Modifier.fillMaxSize().background( + Brush.verticalGradient( + 0f to Color(0x330A0A0A), + 0.55f to Color(0x220A0A0A), + 1f to Color(0xFF0A0A0A), + ), + ), + ) + Box( + Modifier.fillMaxSize().background( + Brush.horizontalGradient( + 0f to Color(0xE60A0A0A), + 0.48f to Color(0x770A0A0A), + 1f to Color.Transparent, + ), + ), + ) + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@Composable +private fun HomeRow( + rowKey: String, + title: String, + items: List, + emptyMessage: String, + loading: Boolean, + density: String, + showMetadata: Boolean, + showResume: Boolean, + onPlay: (BaseItem) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, color = Color(0xFFF0F2F3), fontSize = 20.sp, fontWeight = FontWeight.SemiBold) + } + if (items.isEmpty() && loading) { + Box(Modifier.padding(horizontal = 48.dp)) { HomeRowSkeleton() } + } else if (items.isEmpty()) { + Text(emptyMessage, color = Color(0xFF8F969D), fontSize = 14.sp, modifier = Modifier.padding(horizontal = 48.dp)) + } else { + val rowState = rememberSaveable(rowKey, saver = LazyListState.Saver) { LazyListState() } + LazyRow( + state = rowState, + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 48.dp, vertical = 7.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth().focusGroup().focusRestorer(), + ) { + items(items, key = { it.id }, contentType = { "content-card" }) { item -> + ContentCard( + item = item, + density = density, + showMetadata = showMetadata, + showResume = showResume, + onClick = { onPlay(item) }, + ) + } + } + } +} + +@Composable +private fun HomeRowSkeleton() { + val transition = rememberInfiniteTransition(label = "home-loading") + val alpha by transition.animateFloat( + initialValue = 0.22f, + targetValue = 0.52f, + animationSpec = infiniteRepeatable(tween(850), RepeatMode.Reverse), + label = "home-loading-alpha", + ) + Box( + Modifier.width(300.dp).height(169.dp) + .background(Color(0xFF30353A).copy(alpha = alpha), RoundedCornerShape(8.dp)), + ) +} + +@Composable +private fun ContentCard( + item: BaseItem, + density: String, + showMetadata: Boolean, + showResume: Boolean, + onClick: () -> Unit, +) { + val repo = ServiceLocator.repository + val watchedTicks = item.userData?.playbackPositionTicks ?: 0L + val totalTicks = item.runTimeTicks ?: 0L + val progress = if (totalTicks > 0L) (watchedTicks.toFloat() / totalTicks).coerceIn(0f, 1f) else 0f + + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + // Five cards remain visible across a 16:9 TV, independent of physical screen size. + val cardsAcross = when (density) { "compact" -> 6; "large" -> 4; else -> 5 } + val cardWidth = ((screenWidth - 96.dp - 16.dp * (cardsAcross - 1)) / cardsAcross) + .coerceIn(150.dp, 360.dp) + val cardHeight = cardWidth * 0.56f + val densityScale = LocalDensity.current + val artworkWidthPx = with(densityScale) { cardWidth.roundToPx() }.coerceIn(320, 720) + val artwork = remember(item.id, artworkWidthPx) { + repo.backdropUrl(item, maxWidth = artworkWidthPx) + ?: repo.primaryUrl(item, maxWidth = artworkWidthPx) + } + var focused by remember { mutableStateOf(false) } + val focusScale by animateFloatAsState( + targetValue = if (focused) 1.035f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium, + ), + label = "content-card-focus", + ) + val focusAlpha by animateFloatAsState( + targetValue = if (focused) 1f else 0f, + animationSpec = tween(90), + label = "content-card-outline", + ) + Box( + modifier = Modifier + .width(cardWidth) + .zIndex(if (focused) 1f else 0f) + .graphicsLayer { + scaleX = focusScale + scaleY = focusScale + } + .onFocusChanged { focused = it.isFocused } + .clickable(onClick = onClick), + ) { + Column { + Box( + Modifier + .width(cardWidth) + .height(cardHeight) + .border( + width = 3.dp, + color = Color.White.copy(alpha = focusAlpha), + shape = RoundedCornerShape(8.dp), + ) + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF171A1D)), + ) { + artwork?.let { + AsyncImage( + model = it, + contentDescription = item.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + if (showResume && progress > 0f) { + Box( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(6.dp) + .background(Color(0xCC4B535B)), + ) { + Box( + Modifier + .fillMaxWidth(progress) + .height(6.dp) + .background(Color(0xFF52B54B)), + ) + } + } + } + Text( + item.name, + color = if (focused) Color.White else Color(0xFFD9DDE0), + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 9.dp), + ) + if (showMetadata) { + val secondaryText = buildString { + if (showResume && watchedTicks > 0L) { + append("Resume at ").append(formatPlaybackPosition(watchedTicks)) + } else if (showResume) { + append("Up next") + item.seriesName?.takeIf { it.isNotBlank() }?.let { append(" • ").append(it) } + } else { + item.productionYear?.let { append(it) } + item.runtimeMinutes?.let { + if (isNotEmpty()) append(" · ") + append(formatRuntime(it)) + } + } + }.ifBlank { + when { + item.isEpisode -> item.seriesName.orEmpty() + item.isSeries -> "Series" + item.isMovie -> "Movie" + else -> item.type + } + } + Text( + secondaryText, + color = if (focused) Color(0xFFBFC5CA) else Color(0xFF858D94), + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 3.dp, bottom = 8.dp), + ) + } else { + Spacer(Modifier.height(8.dp)) + } + } + } +} + +private fun formatPlaybackPosition(ticks: Long): String { + val totalMinutes = (ticks / 600_000_000L).toInt().coerceAtLeast(0) + val hours = totalMinutes / 60 + val minutes = totalMinutes % 60 + return if (hours > 0) "${hours}h ${minutes}m" else "${minutes}m" +} + +private fun formatRuntime(minutes: Int): String { + val hours = minutes / 60 + val remainingMinutes = minutes % 60 + return if (hours > 0 && remainingMinutes > 0) "${hours}h ${remainingMinutes}m" + else if (hours > 0) "${hours}h" + else "${remainingMinutes}m" +} + +@Composable +private fun SettingsPanel(settings: Settings, onBack: () -> Unit) { + val context = LocalContext.current + val store = ServiceLocator.settings + val scope = rememberCoroutineScope() + val checker = remember { UpdateChecker(context) } + + // Hardware Back returns to the home screen instead of leaving the app. + BackHandler(onBack = onBack) + + var baseUrl by rememberSaveable { mutableStateOf(settings.updateBaseUrl.orEmpty()) } + var repoPath by rememberSaveable { mutableStateOf(settings.updateRepo.orEmpty()) } + var token by rememberSaveable { mutableStateOf(settings.updateToken.orEmpty()) } + var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) } + var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) } + + var checking by remember { mutableStateOf(false) } + var status by remember { mutableStateOf(null) } + var installMessage by remember { mutableStateOf(null) } + + val firstFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 56.dp, vertical = 48.dp) + .width(760.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Text("Settings", color = Color.White, fontSize = 40.sp, fontWeight = FontWeight.Bold) + + // --- Screensaver appearance --- + Text("Screensaver", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold) + Button( + onClick = { + showLogo = !showLogo + scope.launch { store.setShowTitleLogo(showLogo) } + }, + modifier = Modifier.focusRequester(firstFocus), + ) { Text(if (showLogo) "Show title logo: On" else "Show title logo: Off") } + Text( + "When on, shows each title's logo artwork from Emby instead of plain text " + + "(falls back to text when a title has no logo).", + color = Color(0xFF9AA3AC), + fontSize = 14.sp, + ) + + Text("Spinner colour", color = Color(0xFF9AA3AC), fontSize = 14.sp) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + listOf( + "White" to "FFFFFF", + "Emby green" to "52B54B", + "Netflix red" to "E50914", + ).forEach { (label, hex) -> + val selected = ringColor.equals(hex, ignoreCase = true) + Button( + onClick = { + ringColor = hex + scope.launch { store.setRingColor(hex) } + }, + ) { Text(if (selected) "● $label" else label) } + } + } + + // --- Updates --- + Text("Updates", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold) + Text( + "Installed version: ${checker.installedVersion}", + color = Color(0xFFB9C0C7), + fontSize = 16.sp, + ) + + TvTextField( + label = "Gitea URL (e.g. https://gitea.example.com)", + value = baseUrl, + onValueChange = { baseUrl = it; status = null }, + keyboardType = KeyboardType.Uri, + ) + TvTextField( + label = "Repository (owner/repo)", + value = repoPath, + onValueChange = { repoPath = it; status = null }, + ) + TvTextField( + label = "Access token", + value = token, + onValueChange = { token = it; status = null }, + isPassword = true, + ) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + onClick = { + if (checking) return@Button + checking = true + status = null + installMessage = null + scope.launch { + store.setUpdateConfig(baseUrl, repoPath, token) + status = checker.check(baseUrl, repoPath, token) + checking = false + } + }, + ) { Text(if (checking) "Checking…" else "Check for updates") } + + Button(onClick = onBack) { Text("Back") } + } + + when (val s = status) { + is UpdateStatus.UpToDate -> Text( + "You're on the latest version (${s.version}).", + color = Color(0xFF7BD88F), + fontSize = 16.sp, + ) + is UpdateStatus.Error -> Text(s.message, color = Color(0xFFFF6B6B), fontSize = 16.sp) + is UpdateStatus.Available -> { + Text( + "Update available: ${s.version}", + color = Color(0xFF7BD88F), + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + ) + if (s.notes.isNotBlank()) { + Text(s.notes, color = Color(0xFFB9C0C7), fontSize = 14.sp) + } + Button( + onClick = { + installMessage = "Downloading update…" + scope.launch { + val result = checker.downloadAndInstall(s.apkUrl, token) + installMessage = result.exceptionOrNull()?.message + ?: "Opening the installer…" + } + }, + ) { Text("Download & install") } + } + null -> {} + } + + installMessage?.let { Text(it, color = Color(0xFFB9C0C7), fontSize = 15.sp) } + + Text("About", color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold) + Text( + "${stringResource(R.string.app_name)} ${checker.installedVersion} · by " + + stringResource(R.string.developer_name), + color = Color(0xFF9AA3AC), + fontSize = 14.sp, + ) + } +} + +@Composable +private fun FavoriteCard(item: BaseItem, onClick: () -> Unit) { + val repo = ServiceLocator.repository + val imageUrl = repo.backdropUrl(item, maxWidth = 640) ?: repo.primaryUrl(item) + Card(onClick = onClick, modifier = Modifier.width(320.dp)) { + Column { + Box(Modifier.width(320.dp).height(180.dp).background(Color(0xFF1A2027))) { + if (imageUrl != null) { + AsyncImage( + model = imageUrl, + contentDescription = item.name, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } + Text( + text = item.name, + color = Color.White, + fontSize = 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(10.dp), + ) + } + } +} + +@Composable +private fun TvTextField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + isPassword: Boolean = false, + keyboardType: KeyboardType = KeyboardType.Text, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(label, color = Color(0xFF9AA3AC), fontSize = 14.sp) + Box( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, Color(0xFF3A424B), RoundedCornerShape(8.dp)) + .background(Color(0xFF161B21), RoundedCornerShape(8.dp)) + .padding(horizontal = 16.dp, vertical = 14.dp), + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = TextStyle(color = Color.White, fontSize = 20.sp), + cursorBrush = SolidColor(Color.White), + visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions(keyboardType = if (isPassword) KeyboardType.Password else keyboardType), + modifier = modifier.fillMaxWidth(), + ) + } + } +} + +private fun openScreensaverSettings(context: android.content.Context) { + val candidates = listOf( + AndroidSettings.ACTION_DREAM_SETTINGS, + AndroidSettings.ACTION_DISPLAY_SETTINGS, + AndroidSettings.ACTION_SETTINGS, + ) + for (action in candidates) { + val intent = Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + return + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt new file mode 100644 index 0000000..77829d8 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MaintenanceScreen.kt @@ -0,0 +1,337 @@ +package com.ponzischeme89.memby.ui + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import kotlinx.coroutines.delay + +private val MaintenanceAccent = Color(0xFF52B54B) +private val MaintenanceTitle = Color(0xFFF2F5F7) +private val MaintenanceBody = Color(0xFFAEB7BF) +private val MaintenanceFaint = Color(0xFF7E888F) + +/** How long between automatic retries while the gateway is down. */ +private const val RETRY_SECONDS = 30 + +/** + * Fills the content area while the gateway reports a deliberate outage. + * + * The navigation rail deliberately stays mounted beside this: Settings and Switch user + * are local, so there is no reason to strand the viewer just because content is + * unavailable. Everything here is cheap to draw — a handful of animated floats and plain + * Canvas geometry — because TV GPUs punish blur and layered transparency. + */ +@Composable +fun MaintenanceScreen( + message: String, + contentFocusRequester: FocusRequester, + navigationFocusRequester: FocusRequester, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + val transition = rememberInfiniteTransition(label = "maintenance") + + // One slow drift drives the background glow; one fast-ish phase drives the rings. + val glow by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(9_000, easing = LinearEasing), RepeatMode.Reverse), + label = "glow", + ) + val pulse by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(3_200, easing = LinearEasing)), + label = "pulse", + ) + val gearRotation by transition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable(tween(24_000, easing = LinearEasing)), + label = "gear", + ) + val sweep by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(1_900, easing = LinearEasing)), + label = "sweep", + ) + + // Entrance: content settles in rather than snapping, so the switch from rows to this + // screen reads as intentional. + var entered by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { entered = true } + val entrance by animateFloatAsState( + targetValue = if (entered) 1f else 0f, + animationSpec = tween(420), + label = "maintenance-entrance", + ) + + var secondsLeft by remember { mutableStateOf(RETRY_SECONDS) } + LaunchedEffect(message) { + // Restarts whenever the message changes, so a failed retry resets the clock. + secondsLeft = RETRY_SECONDS + while (true) { + delay(1_000) + secondsLeft -= 1 + if (secondsLeft <= 0) { + onRetry() + secondsLeft = RETRY_SECONDS + } + } + } + + Box(modifier = modifier.fillMaxSize()) { + MaintenanceBackdrop(glow = glow) + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 56.dp) + .graphicsLayer { + alpha = entrance + translationY = (1f - entrance) * 26.dp.toPx() + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PulsingEmblem(pulse = pulse, gearRotation = gearRotation) + + Spacer(Modifier.height(30.dp)) + Text( + "Memby is taking a short break", + color = MaintenanceTitle, + fontSize = 34.sp, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(12.dp)) + Text( + message, + color = MaintenanceBody, + fontSize = 17.sp, + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 620.dp), + ) + + Spacer(Modifier.height(28.dp)) + SweepBar(progress = sweep) + + Spacer(Modifier.height(28.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(18.dp), + ) { + RetryButton( + onClick = { + secondsLeft = RETRY_SECONDS + onRetry() + }, + modifier = Modifier + .focusRequester(contentFocusRequester) + // Without this the viewer can reach the button but never get + // back to the rail, since nothing else here is focusable. + .focusProperties { left = navigationFocusRequester }, + ) + Text( + "Checking again in ${secondsLeft}s", + color = MaintenanceFaint, + fontSize = 14.sp, + ) + } + } + } +} + +/** Vertical wash plus a radial glow that drifts, so the screen is never quite static. */ +@Composable +private fun MaintenanceBackdrop(glow: Float) { + Canvas(Modifier.fillMaxSize()) { + drawRect( + brush = Brush.verticalGradient( + listOf(Color(0xFF0B0F14), Color(0xFF121A22), Color(0xFF0A0D11)), + ), + ) + val centre = Offset( + x = size.width * (0.42f + 0.16f * glow), + y = size.height * (0.38f + 0.10f * (1f - glow)), + ) + val radius = size.minDimension * (0.55f + 0.08f * glow) + drawCircle( + brush = Brush.radialGradient( + colors = listOf(MaintenanceAccent.copy(alpha = 0.13f), Color.Transparent), + center = centre, + radius = radius, + ), + radius = radius, + center = centre, + ) + } +} + +/** + * Three rings expanding outward on staggered phases, with a slowly turning gear at the + * centre. Drawn in one Canvas: three composables with their own animations would cost + * three recompositions per frame for the same picture. + */ +@Composable +private fun PulsingEmblem(pulse: Float, gearRotation: Float) { + Box(contentAlignment = Alignment.Center) { + Canvas(Modifier.size(210.dp)) { + val base = size.minDimension * 0.22f + repeat(3) { index -> + // Stagger the phases so the rings never bunch up together. + val phase = (pulse + index / 3f) % 1f + val radius = base * (1f + phase * 1.6f) + drawCircle( + color = MaintenanceAccent.copy(alpha = 0.34f * (1f - phase)), + radius = radius, + center = center, + style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx()), + ) + } + drawCircle( + brush = Brush.radialGradient( + colors = listOf(MaintenanceAccent.copy(alpha = 0.22f), Color.Transparent), + center = center, + radius = base * 1.35f, + ), + radius = base * 1.35f, + center = center, + ) + } + + Box( + modifier = Modifier + .size(96.dp) + .clip(CircleShape) + .background(Color(0xFF16202A)) + .border(1.dp, MaintenanceAccent.copy(alpha = 0.35f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Build, + contentDescription = null, + tint = MaintenanceAccent, + modifier = Modifier + .size(40.dp) + .graphicsLayer { rotationZ = gearRotation }, + ) + } + } +} + +/** An indeterminate bar: a highlight sweeping a dim track, looping. */ +@Composable +private fun SweepBar(progress: Float) { + Canvas( + Modifier + .width(300.dp) + .height(3.dp), + ) { + val corner = androidx.compose.ui.geometry.CornerRadius(size.height / 2f) + drawRoundRect(color = Color.White.copy(alpha = 0.07f), cornerRadius = corner) + + // The highlight starts off-screen left and exits right, so the loop point is + // invisible rather than a visible jump back to the start. + val bandWidth = size.width * 0.32f + val x = -bandWidth + (size.width + bandWidth) * progress + drawRoundRect( + brush = Brush.horizontalGradient( + colors = listOf(Color.Transparent, MaintenanceAccent.copy(alpha = 0.85f), Color.Transparent), + startX = x, + endX = x + bandWidth, + ), + topLeft = Offset(x.coerceAtLeast(0f), 0f), + size = Size( + width = (x + bandWidth).coerceAtMost(size.width) - x.coerceAtLeast(0f), + height = size.height, + ), + cornerRadius = corner, + ) + } +} + +@Composable +private fun RetryButton(onClick: () -> Unit, modifier: Modifier = Modifier) { + var focused by remember { mutableStateOf(false) } + val scale by animateFloatAsState( + targetValue = if (focused) 1.06f else 1f, + animationSpec = tween(120), + label = "retry-scale", + ) + + Box( + modifier = modifier + .graphicsLayer { scaleX = scale; scaleY = scale } + .clip(RoundedCornerShape(10.dp)) + .background(if (focused) MaintenanceAccent else Color(0xFF1E2833)) + .border( + width = if (focused) 0.dp else 1.dp, + color = Color.White.copy(alpha = 0.12f), + shape = RoundedCornerShape(10.dp), + ) + .onFocusChanged { focused = it.isFocused } + .focusable(interactionSource = remember { MutableInteractionSource() }) + .clickable(onClick = onClick) + .padding(horizontal = 26.dp, vertical = 12.dp), + ) { + Text( + "Try again", + color = if (focused) Color(0xFF06240A) else MaintenanceTitle, + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt new file mode 100644 index 0000000..0ea823a --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -0,0 +1,228 @@ +package com.ponzischeme89.memby.ui.player + +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.KeyEvent +import android.view.WindowManager +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.TrackGroup +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.ui.PlayerView +import androidx.lifecycle.lifecycleScope +import com.ponzischeme89.memby.ServiceLocator +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Fullscreen Media3 player with native stream-track selection. Press Menu while + * playing to choose an audio or subtitle track; the subtitle controller button + * remains available in the regular transport controls too. + */ +class PlayerActivity : ComponentActivity() { + + private var player: ExoPlayer? = null + private var playerView: PlayerView? = null + private var progressJob: Job? = null + private var playbackStarted = false + private var stopReported = false + private var itemId: String? = null + + @UnstableApi + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + val url = intent.getStringExtra(EXTRA_URL) + itemId = intent.getStringExtra(EXTRA_ITEM_ID) + val resumePositionMs = intent.getLongExtra(EXTRA_RESUME_POSITION_MS, 0L) + if (url.isNullOrBlank()) { + finish() + return + } + + val view = PlayerView(this).apply { + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + useController = true + setShowSubtitleButton(true) + } + setContentView(view) + playerView = view + + val selector = DefaultTrackSelector(this) + player = ExoPlayer.Builder(this) + .setTrackSelector(selector) + .build() + .also { playback -> + view.player = playback + playback.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY && !playbackStarted) { + playbackStarted = true + reportStarted(playback.currentPosition) + startProgressReporting() + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (playbackStarted) reportProgress(playback.currentPosition, isPaused = !isPlaying) + } + }) + playback.setMediaItem(MediaItem.fromUri(url), resumePositionMs) + playback.playWhenReady = true + playback.prepare() + } + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_UP && event.keyCode in setOf(KeyEvent.KEYCODE_MENU, KeyEvent.KEYCODE_SETTINGS)) { + showTrackMenu() + return true + } + return super.dispatchKeyEvent(event) + } + + private fun showTrackMenu() { + val playback = player ?: return + val audio = playback.currentTracks.groups.any { group -> + group.type == C.TRACK_TYPE_AUDIO && (0 until group.mediaTrackGroup.length).any(group::isTrackSupported) + } + val subtitles = playback.currentTracks.groups.any { group -> + group.type == C.TRACK_TYPE_TEXT && (0 until group.mediaTrackGroup.length).any(group::isTrackSupported) + } + val options = buildList { + if (audio) add("Audio") + if (subtitles) add("Subtitles") + } + if (options.isEmpty()) { + AlertDialog.Builder(this).setMessage("No alternate audio or subtitle tracks are available.") + .setPositiveButton("OK", null).show() + return + } + AlertDialog.Builder(this) + .setTitle(intent.getStringExtra(EXTRA_TITLE) ?: "Playback options") + .setItems(options.toTypedArray()) { _, which -> + showTrackPicker(if (options[which] == "Audio") C.TRACK_TYPE_AUDIO else C.TRACK_TYPE_TEXT) + } + .show() + } + + private fun showTrackPicker(trackType: Int) { + val playback = player ?: return + val entries = mutableListOf() + playback.currentTracks.groups.forEach { group -> + if (group.type == trackType) { + for (index in 0 until group.mediaTrackGroup.length) { + if (group.isTrackSupported(index)) { + entries += TrackChoice(group.mediaTrackGroup, index, trackLabel(group.mediaTrackGroup, index)) + } + } + } + } + val choices = if (trackType == C.TRACK_TYPE_TEXT) listOf(TrackChoice(null, -1, "Off")) + entries else entries + AlertDialog.Builder(this) + .setTitle(if (trackType == C.TRACK_TYPE_AUDIO) "Audio track" else "Subtitles") + .setItems(choices.map { it.label }.toTypedArray()) { _, which -> + val choice = choices[which] + val builder = playback.trackSelectionParameters.buildUpon().clearOverridesOfType(trackType) + if (choice.group != null) builder.setOverrideForType(TrackSelectionOverride(choice.group, listOf(choice.index))) + playback.trackSelectionParameters = builder.build() + } + .show() + } + + private fun trackLabel(group: TrackGroup, index: Int): String { + val format = group.getFormat(index) + return format.label?.takeIf { it.isNotBlank() } + ?: format.language?.takeIf { it.isNotBlank() }?.replaceFirstChar { it.uppercase() } + ?: if (format.channelCount > 0) "${format.channelCount} channel audio" else "Track ${index + 1}" + } + + override fun onStop() { + player?.let { if (playbackStarted) reportProgress(it.currentPosition, isPaused = true) } + super.onStop() + player?.pause() + } + + override fun onDestroy() { + progressJob?.cancel() + val playback = player + if (!stopReported && playbackStarted && !itemId.isNullOrBlank()) { + stopReported = true + ServiceLocator.repository.enqueuePlaybackStopped(itemId!!, playback?.currentPosition ?: 0L) + } + playerView?.player = null + playback?.release() + player = null + super.onDestroy() + } + + private fun reportStarted(positionMs: Long) { + val id = itemId?.takeIf { it.isNotBlank() } ?: return + lifecycleScope.launch { + runCatching { ServiceLocator.repository.reportPlaybackStarted(id, positionMs) } + } + } + + private fun reportProgress(positionMs: Long, isPaused: Boolean) { + val id = itemId?.takeIf { it.isNotBlank() } ?: return + lifecycleScope.launch { + runCatching { ServiceLocator.repository.reportPlaybackProgress(id, positionMs, isPaused) } + } + } + + private fun startProgressReporting() { + progressJob?.cancel() + progressJob = lifecycleScope.launch { + while (isActive) { + delay(PROGRESS_INTERVAL_MS) + player?.let { reportProgress(it.currentPosition, isPaused = !it.isPlaying) } + } + } + } + + private data class TrackChoice(val group: TrackGroup?, val index: Int, val label: String) + + companion object { + private const val EXTRA_URL = "extra_url" + private const val EXTRA_ITEM_ID = "extra_item_id" + private const val EXTRA_TITLE = "extra_title" + private const val EXTRA_RESUME_POSITION_MS = "extra_resume_position_ms" + + fun intent( + context: Context, + url: String, + title: String?, + resumePositionMs: Long = 0L, + ): Intent = intent(context, itemId = null, url = url, title = title, resumePositionMs = resumePositionMs) + + fun intent( + context: Context, + itemId: String?, + url: String, + title: String?, + resumePositionMs: Long = 0L, + ): Intent = + Intent(context, PlayerActivity::class.java).apply { + itemId?.let { putExtra(EXTRA_ITEM_ID, it) } + putExtra(EXTRA_URL, url) + putExtra(EXTRA_TITLE, title) + putExtra(EXTRA_RESUME_POSITION_MS, resumePositionMs) + } + + private const val PROGRESS_INTERVAL_MS = 10_000L + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/EmbyAppLauncher.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/EmbyAppLauncher.kt new file mode 100644 index 0000000..7daf6d2 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/EmbyAppLauncher.kt @@ -0,0 +1,33 @@ +package com.ponzischeme89.memby.ui.screensaver + +import android.content.Context +import android.content.Intent +import android.net.Uri + +/** Opens an item in Emby's installed Android or Android TV client. */ +internal object EmbyAppLauncher { + private val packageCandidates = listOf("com.mb.android", "tv.emby.embyatv") + + fun play(context: Context, serverId: String?, itemId: String): Boolean { + if (serverId.isNullOrBlank() || itemId.isBlank()) return false + + // `play` is supported by recent Emby Android builds. `items` keeps navigation + // useful for older installed clients that only support opening an item page. + val links = listOf( + "emby://play/$serverId/$itemId", + "emby://items/$serverId/$itemId", + ) + for (packageName in packageCandidates) { + for (link in links) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link)) + .setPackage(packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (intent.resolveActivity(context.packageManager) != null) { + context.startActivity(intent) + return true + } + } + } + return false + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt new file mode 100644 index 0000000..340f0af --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverActivity.kt @@ -0,0 +1,45 @@ +package com.ponzischeme89.memby.ui.screensaver + +import android.os.Bundle +import android.os.Build +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.ponzischeme89.memby.ui.player.PlayerActivity +import com.ponzischeme89.memby.ui.theme.MembyTheme + +/** In-app preview of the screensaver, launched from the home screen. */ +class ScreensaverActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + // A package replacement can leave Android TV in the Dream's ambient/sleeping + // state. The restart destination is an interactive slide, so wake the display + // as this activity becomes visible instead of rendering it behind black. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setTurnScreenOn(true) + } else { + @Suppress("DEPRECATION") + window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON) + } + setContent { + MembyTheme { + ScreensaverContent( + onPlay = { url, title -> + startActivity(PlayerActivity.intent(this, url, title)) + }, + onExit = { finish() }, + startupMessage = intent.getStringExtra(EXTRA_STARTUP_MESSAGE), + ) + } + } + } + + companion object { + const val EXTRA_STARTUP_MESSAGE = "com.ponzischeme89.memby.extra.STARTUP_MESSAGE" + + fun restartAfterUpdateIntent(context: android.content.Context): android.content.Intent = + android.content.Intent(context, ScreensaverActivity::class.java) + .putExtra(EXTRA_STARTUP_MESSAGE, "Restarting Memby after update…") + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt new file mode 100644 index 0000000..720eb66 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/screensaver/ScreensaverContent.kt @@ -0,0 +1,997 @@ +package com.ponzischeme89.memby.ui.screensaver + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.Business +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.LiveTv +import androidx.compose.material.icons.filled.Movie +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.LocalOffer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameMillis +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.graphics.drawable.toBitmap +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import androidx.tv.material3.Button +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import coil.imageLoader +import coil.request.ImageRequest +import coil.request.SuccessResult +import com.ponzischeme89.memby.R +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.friendlyEmbyError +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.ui.settings.SettingsSheet +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date + +// Background-prefetch tuning for the rotating queue. +private const val PREFETCH_AHEAD = 6 // start fetching this many items before the end +private const val MAX_QUEUE = 600 // cap the in-memory queue… +private const val TRIM_TO = 400 // …trimming already-shown items down to this + +/** Soft drop shadow that keeps foreground text legible over bright/white artwork. */ +private val TextShadow = Shadow( + color = Color(0xCC000000), + offset = Offset(0f, 3f), + blurRadius = 10f, +) + +/** + * A small holder that lets a host (e.g. the DreamService) drive playback from a + * key it intercepts outside Compose focus (the remote's Play/Pause media key). + * The composable registers [playCurrent] for the currently shown item. + */ +class ScreensaverActions { + @Volatile + var playCurrent: (() -> Unit)? = null +} + +/** + * The shared backdrop slideshow, used by both the in-app preview activity and the + * system Daydream. Remote model: + * - ◄ / ► previous / next item (when the panel is closed) + * - ▲ / ▼ show / hide the details + actions panel + * - OK/Center open the panel, or perform the focused action when it's open + * - Play/Pause start playback of the current item + * - Back close the panel, or exit if it's already closed + */ +@Composable +fun ScreensaverContent( + onPlay: (url: String, title: String) -> Unit, + onExit: () -> Unit, + actions: ScreensaverActions? = null, + startupMessage: String? = null, +) { + val repo = ServiceLocator.repository + // Nullable initial so we don't flash "not configured" before settings load. + val settings by repo.settingsFlow.collectAsState(initial = null) + + when { + settings == null -> MessageScreen(text = "Memby starting up….", onExit = onExit) + settings?.isSignedIn != true -> MessageScreen( + text = "Open “Memby” on this device to configure your server and sign in.", + onExit = onExit, + ) + else -> Slideshow( + onPlay = onPlay, + onExit = onExit, + actions = actions, + showTitleLogo = settings?.showTitleLogo ?: true, + ringColor = ringColorFromHex(settings?.ringColorHex), + embyServerId = settings?.serverId, + warmBackdropUrl = settings?.lastBackdropUrl, + startupMessage = startupMessage, + ) + } +} + +@Composable +private fun Slideshow( + onPlay: (url: String, title: String) -> Unit, + onExit: () -> Unit, + actions: ScreensaverActions?, + showTitleLogo: Boolean, + ringColor: Color, + embyServerId: String?, + warmBackdropUrl: String?, + startupMessage: String?, +) { + val repo = ServiceLocator.repository + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var items by remember { mutableStateOf>(emptyList()) } + var index by remember { mutableIntStateOf(0) } + var panelOpen by remember { mutableStateOf(false) } + var settingsOpen by remember { mutableStateOf(false) } + var loading by remember { mutableStateOf(true) } + var loadingMore by remember { mutableStateOf(false) } + var loadError by remember { mutableStateOf(null) } + var toast by remember(startupMessage) { mutableStateOf(startupMessage) } + var reloadKey by remember { mutableIntStateOf(0) } + val favoriteOverrides = remember { mutableStateMapOf() } + val favoriteMutations = remember { mutableStateMapOf() } + + val rootFocus = remember { FocusRequester() } + val playFocus = remember { FocusRequester() } + val logoRotation = remember { Animatable(0f) } + val introBrandAlpha = remember { Animatable(0f) } + val slideRevealShade = remember { Animatable(0f) } + + // Normalised slide progress in [0f,1f]. A single coroutine (the slide timer below) + // drives both this ring and the slide advance, so they share one clock and can't drift. + val slideProgress = remember { mutableFloatStateOf(0f) } + val lifecycleOwner = LocalLifecycleOwner.current + + LaunchedEffect(reloadKey) { + loading = true + loadError = null + runCatching { repo.getScreensaverItems() } + .onSuccess { fetched -> + val queue = fetched.shuffled() + // The lightweight startup request may already be on-screen. Keep that + // exact item at the front rather than swapping through several results + // as competing requests finish; the rest of the random queue follows it. + val visible = items.getOrNull(index) + if (visible == null) { + items = queue + index = 0 + } else { + items = listOf(visible) + queue.filter { it.id != visible.id } + index = 0 + } + loading = false + } + .onFailure { loadError = friendlyEmbyError(it); loading = false } + } + + // Do not wait for the full 200-item queue before showing a first real backdrop. + // This runs alongside it and normally wins on a cold launch. + LaunchedEffect(reloadKey) { + runCatching { repo.getStartupBackdropMovie() } + .onSuccess { movie -> + if (movie != null && items.isEmpty()) { + items = listOf(movie) + loading = false + } + } + } + + // Fetches another random batch in the background as the user nears the end of + // the current queue and appends the new (deduped) items — so the screensaver + // keeps surfacing fresh titles instead of looping the first batch forever. The + // queue is trimmed from the front to stay bounded over long uptimes. + fun maybePrefetch() { + val size = items.size + if (loadingMore || size == 0 || index < size - PREFETCH_AHEAD) return + loadingMore = true + toast = "Finding more from your library…" + scope.launch { + val more = runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList()) + if (more.isNotEmpty()) { + val existing = items.mapTo(HashSet()) { it.id } + val fresh = more.filter { it.id !in existing } + // If the library has nothing new, re-use the reshuffled batch so it + // still keeps moving (just in a different random order). + val refill = fresh.ifEmpty { more }.shuffled() + var merged = items + refill + if (merged.size > MAX_QUEUE) { + val drop = merged.size - TRIM_TO + merged = merged.drop(drop) + index = (index - drop).coerceAtLeast(0) + } + items = merged + toast = if (fresh.isNotEmpty()) { + "Queued ${fresh.size} more titles" + } else { + "Refreshed the queue with a new order" + } + } else { + toast = "No additional titles found" + } + loadingMore = false + } + } + + fun advance() { + val size = items.size + if (size == 0) return + // Loop back only as a fallback if a refill hasn't landed yet. + index = if (index + 1 < size) index + 1 else 0 + maybePrefetch() + } + + // The one authoritative slide timer is defined below — after `hasContent` and the + // navigation helpers it depends on — so there is a single source of truth. + + // Return focus to the root when the panel closes so key events keep flowing. + // Guarded: the Play button lives inside AnimatedVisibility and may attach a + // frame late; requestFocus() on an unattached requester would otherwise throw. + LaunchedEffect(panelOpen, settingsOpen, items.isEmpty()) { + if (settingsOpen) { + // SettingsSheet owns focus while it is on screen. + } else if (panelOpen) { + delay(50) + runCatching { playFocus.requestFocus() } + } else { + runCatching { rootFocus.requestFocus() } + } + } + + // Auto-dismiss transient toasts. + LaunchedEffect(toast) { + if (toast != null) { delay(3500); toast = null } + } + + val current = items.getOrNull(index) + val currentUpdated by rememberUpdatedState(current) + val isFav = current?.let { favoriteOverrides[it.id] ?: it.isFavorite } ?: false + val hasContent = items.isNotEmpty() + // Keep a real Emby backdrop available for the next cold start. Coil's disk cache + // makes this appear instantly in the usual case, before the fresh queue arrives. + LaunchedEffect(current?.id) { + current?.let { item -> + repo.backdropUrl(item)?.let { ServiceLocator.settings.setLastBackdropUrl(it) } + } + } + + // A brief full turn gives the persistent brand mark a small, purposeful cue every + // time the current title changes — including manual previous/next navigation. + LaunchedEffect(index) { + logoRotation.snapTo(0f) + logoRotation.animateTo(360f, animationSpec = tween(durationMillis = 1_100)) + } + LaunchedEffect(index) { + slideRevealShade.snapTo(0.38f) + slideRevealShade.animateTo(0f, animationSpec = tween(durationMillis = 1_100)) + } + // A small first-launch signature: the app name appears beside the Emby mark and + // quietly disappears, leaving the artwork to take over. + LaunchedEffect(Unit) { + introBrandAlpha.snapTo(0f) + introBrandAlpha.animateTo(0.94f, animationSpec = tween(durationMillis = 500)) + delay(1_900) + introBrandAlpha.animateTo(0f, animationSpec = tween(durationMillis = 900)) + } + + fun next() { if (items.isNotEmpty()) { index = (index + 1) % items.size; maybePrefetch() } } + fun prev() { if (items.isNotEmpty()) index = (index - 1 + items.size) % items.size } + + // Single authoritative slide timer. One lifecycle-aware coroutine animates the + // progress ring 0f→1f over the slide duration and then advances, so the ring and + // the slide change share one clock and cannot drift. It restarts (snapping progress + // back to 0) whenever the slide changes (auto or manual), the queue reloads, or the + // panel opens/closes. repeatOnLifecycle pauses it while the Activity/DreamService is + // below RESUMED and resets it on resume; leaving the composition cancels it, so no + // duplicate timers survive a lifecycle change. Not keyed on the items list, so + // background refills don't restart it. + LaunchedEffect(index, panelOpen, settingsOpen, reloadKey, hasContent) { + if (panelOpen || settingsOpen || !hasContent) return@LaunchedEffect + lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + val durationMillis = repo.rotationIntervalMillis() + slideProgress.floatValue = 0f + // Drive progress from real elapsed frame time rather than a tween, so each + // slide lasts exactly `durationMillis` and keeps progressing even when the + // system animator-duration scale is 0. withFrameMillis keeps it smooth. + val startMillis = withFrameMillis { it } + var elapsed = 0L + while (elapsed < durationMillis) { + elapsed = withFrameMillis { it } - startMillis + slideProgress.floatValue = (elapsed.toFloat() / durationMillis).coerceIn(0f, 1f) + } + advance() + } + } + + fun playTrailer(item: BaseItem?) { + val target = item ?: return + toast = "Finding trailer…" + scope.launch { + runCatching { repo.getLocalTrailer(target.id) } + .onSuccess { trailer -> + if (trailer == null) { + toast = "No trailer is available for ${target.name}." + } else { + runCatching { repo.resolvePlayable(trailer) } + .onSuccess { onPlay(it.url, "${target.name} trailer") } + .onFailure { toast = friendlyEmbyError(it) } + } + } + .onFailure { toast = friendlyEmbyError(it) } + } + } + + fun setFavorite(desired: Boolean) { + val item = current ?: return + val previous = favoriteOverrides[item.id] ?: item.isFavorite + val mutation = (favoriteMutations[item.id] ?: 0) + 1 + favoriteMutations[item.id] = mutation + favoriteOverrides[item.id] = desired // optimistic + toast = if (desired) "Added to favourites" else "Removed from favourites" + scope.launch { + runCatching { repo.setFavorite(item.id, desired) } + .onSuccess { if (favoriteMutations[item.id] == mutation) favoriteOverrides[item.id] = it } + .onFailure { + if (favoriteMutations[item.id] == mutation) { + favoriteOverrides[item.id] = previous + toast = friendlyEmbyError(it) + } + } + } + } + + // Let a host (DreamService) trigger playback from the media key. + DisposableEffect(actions) { + actions?.playCurrent = { playTrailer(currentUpdated) } + onDispose { actions?.playCurrent = null } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + .focusRequester(rootFocus) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + // The slide root normally owns directional navigation. Once Settings is + // open, leave those keys to the sheet's focused controls; only Back is + // handled here so it reliably dismisses the overlay. + if (settingsOpen) { + return@onPreviewKeyEvent when (event.key) { + Key.Back -> { settingsOpen = false; true } + else -> false + } + } + when (event.key) { + Key.Back -> { + when { + panelOpen -> { panelOpen = false; true } + else -> { onExit(); true } + } + } + Key.MediaPlay, Key.MediaPlayPause -> { playTrailer(current); true } + else -> if (!hasContent) { + // Loading / error state: OK retries, other keys ignored. + when (event.key) { + Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> { + if (loadError != null) reloadKey++; true + } + else -> false + } + } else when (event.key) { + Key.DirectionUp, Key.DirectionDown -> { panelOpen = !panelOpen; true } + Key.DirectionCenter, Key.Enter, Key.NumPadEnter -> + if (!panelOpen) { panelOpen = true; true } else false + Key.DirectionRight -> if (!panelOpen) { next(); true } else false + Key.DirectionLeft -> if (!panelOpen) { prev(); true } else false + else -> false + } + } + }, + contentAlignment = Alignment.Center, + ) { + if (current == null) { + if (warmBackdropUrl != null) { + AsyncImage( + model = ImageRequest.Builder(context).data(warmBackdropUrl).crossfade(false).build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } else { + // First-ever start: a calm loading surface instead of a blank black frame. + Box( + Modifier.fillMaxSize().background( + Brush.linearGradient( + listOf(Color(0xFF17232B), Color(0xFF0B0E11), Color(0xFF101D17)), + ), + ), + ) + } + } + // Backdrop with a cross-fade between items. + Crossfade( + targetState = current, + animationSpec = tween(durationMillis = 1200), + label = "backdrop", + ) { item -> + val url = item?.let { repo.backdropUrl(it) } + if (url != null) { + AsyncImage( + model = ImageRequest.Builder(context).data(url).crossfade(false).build(), + contentDescription = item.name, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + // Keep 60fps zoom work in the draw layer, avoiding full + // recomposition of all slide content on every frame. + val p = slideProgress.floatValue.coerceIn(0f, 1f) + val eased = p * p * (3f - 2f * p) + val scale = 1.28f - (0.28f * eased) + scaleX = scale + scaleY = scale + }, + ) + } + } + + // A short shadow reveal makes the backdrop transition more deliberate without + // obscuring the title treatment that follows it. + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = slideRevealShade.value)), + ) + + // L-shaped scrim (bottom + left) so text stays readable even over bright + // or near-white backdrops. The left gradient anchors the text column; the + // bottom gradient covers the info/actions area. + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0.30f to Color.Transparent, + 1f to Color(0xF7000000), + ) + ) + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + 0f to Color(0xC0000000), + 0.55f to Color.Transparent, + ) + ) + ) + + // Persistent Emby brand mark in the very top-left, shown on every slide. Purely + // decorative — takes no focus and makes no accessibility announcement. + Row( + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 32.dp, top = 32.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Image( + painter = painterResource(R.drawable.emby_logo), + contentDescription = null, + modifier = Modifier + .size(64.dp) + .graphicsLayer { rotationZ = logoRotation.value } + .clearAndSetSemantics {}, + ) + Text( + text = "Memby", + color = Color.White, + fontSize = 25.sp, + fontWeight = FontWeight.SemiBold, + style = TextStyle(shadow = TextShadow), + modifier = Modifier.graphicsLayer { alpha = introBrandAlpha.value }, + ) + } + + if (current != null) { + InfoAndActions( + item = current, + isFavorite = isFav, + panelOpen = panelOpen, + showTitleLogo = showTitleLogo, + playFocus = playFocus, + onPlay = { playTrailer(current) }, + onToggleFavorite = { setFavorite(!isFav) }, + onNext = ::next, + onPrev = ::prev, + onOpenSettings = { settingsOpen = true }, + onExit = onExit, + modifier = Modifier.align(Alignment.BottomStart), + ) + } + + if (!hasContent) { + Text( + text = when { + loading -> "Memby starting up…." + loadError != null -> "$loadError\n\nPress OK to retry." + else -> "No movies or shows with backdrops were found." + }, + color = Color.White, + fontSize = 22.sp, + modifier = Modifier.padding(48.dp), + ) + } + + toast?.let { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(32.dp) + .background(Color(0xCC000000), RoundedCornerShape(8.dp)) + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Text(text = it, color = Color.White, fontSize = 16.sp) + } + } + + // Subtle circular slide-progress indicator (bottom-right). Hidden while the + // panel is open (slideshow paused) or before content loads. Reads progress in + // the draw phase so it repaints per frame without recomposing the slideshow. + if (hasContent && !panelOpen) { + SlideProgressRing( + progress = { slideProgress.floatValue }, + color = ringColor, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 48.dp, bottom = 43.dp), + ) + } + + CurrentTime( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 98.dp, bottom = 41.dp), + ) + + if (settingsOpen) { + SettingsSheet( + editableServer = true, + onClose = { settingsOpen = false }, + onInstallerLaunched = onExit, + ) + } + } +} + +/** A high-legibility, always-current clock beside the slide-progress indicator. */ +@Composable +private fun CurrentTime(modifier: Modifier = Modifier) { + var now by remember { mutableStateOf(System.currentTimeMillis()) } + LaunchedEffect(Unit) { + while (true) { + now = System.currentTimeMillis() + // Tick exactly after the next minute changes, rather than polling each second. + delay(60_000L - (now % 60_000L) + 30L) + } + } + val formatter = remember { DateFormat.getTimeInstance(DateFormat.SHORT) } + Text( + text = formatter.format(Date(now)), + color = Color.White.copy(alpha = 0.92f), + fontSize = 30.sp, + fontWeight = FontWeight.SemiBold, + style = TextStyle(shadow = TextShadow), + modifier = modifier, + ) +} + +/** + * A small, understated circular slide-progress indicator. Fills clockwise from the + * 12 o'clock position over the slide duration. Purely decorative: it takes no focus + * and makes no accessibility announcements. + * + * [progress] is read lazily inside the draw phase (a `() -> Float`) so the ring + * repaints each frame as the value animates without recomposing its caller. + */ +@Composable +private fun SlideProgressRing( + progress: () -> Float, + color: Color, + modifier: Modifier = Modifier, +) { + Canvas( + modifier = modifier + .size(32.dp) + .clearAndSetSemantics {}, + ) { + val stroke = 3.dp.toPx() + val inset = stroke / 2f + val arcSize = Size(size.width - stroke, size.height - stroke) + val topLeft = Offset(inset, inset) + + // Faint dark disc so the ring stays legible over bright/near-white artwork. + drawCircle(color = Color.Black.copy(alpha = 0.30f), radius = size.minDimension / 2f) + // Thin semi-transparent background ring. + drawArc( + color = Color.White.copy(alpha = 0.28f), + startAngle = 0f, + sweepAngle = 360f, + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + // Foreground arc (the chosen colour) showing elapsed progress, clockwise from + // 12 o'clock. + drawArc( + color = color.copy(alpha = 0.90f), + startAngle = -90f, + sweepAngle = SlideProgressMath.sweepAngle(progress()), + useCenter = false, + topLeft = topLeft, + size = arcSize, + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + } +} + +/** Parses an RRGGBB hex string to an opaque [Color], falling back to white. */ +internal fun ringColorFromHex(hex: String?): Color = + runCatching { Color(("FF" + (hex ?: "").removePrefix("#").trim()).toLong(16)) } + .getOrDefault(Color.White) + +/** Pure geometry for [SlideProgressRing], split out so it is unit-testable. */ +internal object SlideProgressMath { + /** Clockwise sweep in degrees for a normalised [progress], clamped to [0f,1f]. */ + fun sweepAngle(progress: Float): Float = progress.coerceIn(0f, 1f) * 360f +} + +@Composable +private fun InfoAndActions( + item: BaseItem, + isFavorite: Boolean, + panelOpen: Boolean, + showTitleLogo: Boolean, + playFocus: FocusRequester, + onPlay: () -> Unit, + onToggleFavorite: () -> Unit, + onNext: () -> Unit, + onPrev: () -> Unit, + onOpenSettings: () -> Unit, + onExit: () -> Unit, + modifier: Modifier = Modifier, +) { + val tagline = item.taglines + .asSequence() + .map { it.trim().trim('"') } + .firstOrNull { it.length > 2 } + Row( + modifier = modifier + .fillMaxWidth() + // Without a tagline the block is shorter; lift it to retain the same + // visual balance rather than letting the title fall toward the controls. + .padding(start = 56.dp, bottom = 30.dp, end = 56.dp), + horizontalArrangement = Arrangement.spacedBy(28.dp), + verticalAlignment = Alignment.Bottom, + ) { + // Poster (only meaningful once the user opens the panel). + if (panelOpen) { + val repo = ServiceLocator.repository + val posterUrl = repo.primaryUrl(item) + if (posterUrl != null) { + AsyncImage( + model = posterUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(150.dp) + .height(225.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF1A2027)), + ) + } + } + + Column( + // Wider on big screens so titles and the synopsis run further across. + // A little narrower when the panel (and poster) is open to leave room. + modifier = Modifier.fillMaxWidth(if (panelOpen) 0.74f else 0.82f), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Prefer the item's Emby "Logo" artwork over a plain-text title, when the + // user has it enabled and this item actually has a logo image. + val logoUrl = if (showTitleLogo) ServiceLocator.repository.logoUrl(item) else null + if (!useTextTitleForLogo(logoUrl)) { + AsyncImage( + model = logoUrl, + contentDescription = item.name, + contentScale = ContentScale.Fit, + alignment = Alignment.CenterStart, + modifier = Modifier + // A fixed logo stage keeps artwork consistently sized even when + // Emby supplies very wide or compact title treatments. + .width(320.dp) + .height(96.dp), + ) + } else { + Text( + text = item.name, + color = Color.White, + fontSize = 46.sp, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = TextStyle(shadow = TextShadow), + ) + } + tagline?.let { tagline -> + Text( + text = tagline, + color = Color(0xFFE4E8EC), + fontSize = 21.sp, + fontStyle = FontStyle.Italic, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = TextStyle(shadow = TextShadow), + ) + } + MediaMetadata(item) + + // Keep the compact facts visually distinct from the plot synopsis. + Spacer(Modifier.height(6.dp)) + + StatusChips(item = item, isFavorite = isFavorite) + + item.overview?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + color = Color(0xFFE4E8EC), + fontSize = 19.sp, + lineHeight = 26.sp, + maxLines = if (panelOpen) 6 else 4, + overflow = TextOverflow.Ellipsis, + style = TextStyle(shadow = TextShadow), + ) + } + + AnimatedVisibility( + visible = panelOpen, + enter = fadeIn(), + exit = fadeOut(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button(onClick = onPlay, modifier = Modifier.focusRequester(playFocus)) { + Icon(Icons.Default.PlayArrow, contentDescription = null) + Text(text = " Play trailer", modifier = Modifier.padding(start = 4.dp)) + } + Button(onClick = onToggleFavorite) { + Icon( + imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder, + contentDescription = null, + ) + Text( + text = if (isFavorite) " Remove favourite" else " Add favourite", + modifier = Modifier.padding(start = 4.dp), + ) + } + Spacer(Modifier.weight(1f)) + Button(onClick = onPrev) { Icon(Icons.Default.ChevronLeft, contentDescription = "Previous") } + Button(onClick = onNext) { Icon(Icons.Default.ChevronRight, contentDescription = "Next") } + Button(onClick = onOpenSettings) { + Icon(Icons.Default.Settings, contentDescription = "Settings") + } + Button(onClick = onExit) { + Icon(Icons.Default.Close, contentDescription = "Exit screensaver") + } + } + } + + if (!panelOpen) { + Text( + text = "▲ options · ◄ ► change · ▶ play", + color = Color(0x99FFFFFF), + fontSize = 15.sp, + modifier = Modifier.padding(top = 6.dp).width(560.dp), + ) + } + } + } +} + +/** + * Transparent Emby logos are commonly black. They disappear over a dark backdrop, so + * inspect a small decoded copy and retain the text title when its visible pixels are + * overwhelmingly dark. Until the image has been inspected, text is the safe default. + */ +@Composable +private fun useTextTitleForLogo(logoUrl: String?): Boolean { + if (logoUrl == null) return true + val context = LocalContext.current + val isDark by produceState(initialValue = true, logoUrl) { + value = runCatching { + val result = context.imageLoader.execute( + ImageRequest.Builder(context) + .data(logoUrl) + .allowHardware(false) + .size(64, 64) + .build(), + ) as? SuccessResult ?: return@runCatching true + isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64)) + }.getOrDefault(true) + } + return isDark +} + +private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean { + var opaquePixels = 0 + var darkPixels = 0 + for (y in 0 until bitmap.height step 2) { + for (x in 0 until bitmap.width step 2) { + val pixel = bitmap.getPixel(x, y) + if (android.graphics.Color.alpha(pixel) < 48) continue + opaquePixels++ + val luminance = ( + android.graphics.Color.red(pixel) * 0.2126f + + android.graphics.Color.green(pixel) * 0.7152f + + android.graphics.Color.blue(pixel) * 0.0722f + ) + if (luminance < 58f) darkPixels++ + } + } + return opaquePixels < 12 || darkPixels.toFloat() / opaquePixels > 0.82f +} + +@Composable +private fun StatusChips(item: BaseItem, isFavorite: Boolean) { + val chips = buildList { + if (isFavorite) add("♥ Favourite") + val ud = item.userData + when { + ud?.played == true -> add("✓ Watched") + (ud?.playbackPositionTicks ?: 0L) > 0L -> add("▶ Resume") + } + } + if (chips.isEmpty()) return + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + chips.forEach { label -> + Box( + modifier = Modifier + .background(Color(0x33FFFFFF), RoundedCornerShape(6.dp)) + .padding(horizontal = 12.dp, vertical = 5.dp), + ) { + Text(text = label, color = Color.White, fontSize = 14.sp) + } + } + } +} + +@Composable +private fun MessageScreen(text: String, onExit: () -> Unit) { + val focus = remember { FocusRequester() } + LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + listOf(Color(0xFF17232B), Color(0xFF0B0E11), Color(0xFF101D17)), + ), + ) + .focusRequester(focus) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Back) { onExit(); true } else false + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + color = Color.White, + fontSize = 24.sp, + modifier = Modifier.fillMaxWidth(0.6f).padding(48.dp), + ) + } +} + +/** Compact, icon-led metadata: the media type remains clear without a bulky text label. */ +@Composable +private fun MediaMetadata(item: BaseItem) { + val metadataColor = Color(0xFFC7CED4) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon( + imageVector = if (item.isSeries) Icons.Default.LiveTv else Icons.Default.Movie, + contentDescription = if (item.isSeries) "Series" else "Movie", + tint = metadataColor, + modifier = Modifier.size(20.dp), + ) + item.productionYear?.let { MetadataText(it.toString(), metadataColor) } + item.communityRating?.let { MetadataText("★ ${"%.1f".format(it)}", metadataColor) } + item.runtimeMinutes?.let { + Icon(Icons.Default.AccessTime, contentDescription = "Runtime", tint = metadataColor, modifier = Modifier.size(18.dp)) + MetadataText("$it min", metadataColor) + } + item.studios.firstOrNull { it.name.isNotBlank() }?.name?.let { + Icon(Icons.Default.Business, contentDescription = "Studio", tint = metadataColor, modifier = Modifier.size(18.dp)) + MetadataText(it, metadataColor) + } + item.genres.firstOrNull()?.takeIf { it.isNotBlank() }?.let { + Icon(Icons.Default.LocalOffer, contentDescription = "Genre", tint = metadataColor, modifier = Modifier.size(18.dp)) + MetadataText(it, metadataColor) + } + } +} + +@Composable +private fun MetadataText(text: String, color: Color) { + Text( + text = text, + color = color, + fontSize = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TextStyle(shadow = TextShadow), + ) +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt new file mode 100644 index 0000000..fae308b --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -0,0 +1,420 @@ +package com.ponzischeme89.memby.ui.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Button +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.ponzischeme89.memby.R +import com.ponzischeme89.memby.ServiceLocator +import com.ponzischeme89.memby.data.Settings +import com.ponzischeme89.memby.update.UpdateChecker +import com.ponzischeme89.memby.update.UpdateStatus +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private data class SpinnerOption(val label: String, val hex: String, val color: Color) + +private val spinnerOptions = listOf( + SpinnerOption("White", "FFFFFF", Color.White), + SpinnerOption("Emby green", "52B54B", Color(0xFF52B54B)), + SpinnerOption("Netflix red", "E50914", Color(0xFFE50914)), +) + +private val Muted = Color(0xFF9AA3AC) +private val Faint = Color(0xFFB9C0C7) +private val SettingsSurface = Color(0xFF1A1A1A) +private val FocusSurface = Color(0xFF3D3D3D) + +/** + * A polished settings panel that slides out from the right edge over a dimming scrim. + * Shared by the home screen and the in-slideshow overlay so both look and behave the + * same. It reads/writes the shared [Settings] via [ServiceLocator]. + * + * Hosts own the Back key (this composable can't assume an OnBackPressedDispatcher — the + * DreamService has none): the home screen wraps it in a BackHandler, the slideshow + * closes it from its own key handler. A visible "Close" button is always provided too. + * + * @param editableServer when true, shows the Gitea URL/repo/token fields (home screen). + * The in-slideshow overlay passes false, since a soft keyboard isn't usable there. + * @param overlay true when shown over a slide; false when it is the launcher activity. + * @param onInstallerLaunched called after Android's package installer has been opened. + * A Dream host uses this to release its window before the APK replacement kills it. + */ +@Composable +fun SettingsSheet( + editableServer: Boolean, + onClose: () -> Unit, + overlay: Boolean = true, + onInstallerLaunched: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val store = ServiceLocator.settings + val scope = rememberCoroutineScope() + val checker = remember { UpdateChecker(context) } + val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY) + + var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) } + var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) } + var baseUrl by rememberSaveable { mutableStateOf(settings.updateBaseUrl.orEmpty()) } + var repoPath by rememberSaveable { mutableStateOf(settings.updateRepo.orEmpty()) } + var token by rememberSaveable { mutableStateOf(settings.updateToken.orEmpty()) } + var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',')) } + var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) } + var showCardMetadata by rememberSaveable { mutableStateOf(settings.showHomeCardMetadata) } + + var checking by remember { mutableStateOf(false) } + var status by remember { mutableStateOf(null) } + var installMessage by remember { mutableStateOf(null) } + + // DataStore arrives after the first composition. Mirror its snapshot into the + // editable state so opening the panel always shows the user's actual choices, + // rather than the empty/default placeholder used while it loads. + LaunchedEffect( + settings.showTitleLogo, + settings.ringColorHex, + settings.updateBaseUrl, + settings.updateRepo, + settings.updateToken, + settings.homeSections, + settings.homeCardDensity, + settings.showHomeCardMetadata, + ) { + showLogo = settings.showTitleLogo + ringColor = settings.ringColorHex + baseUrl = settings.updateBaseUrl.orEmpty() + repoPath = settings.updateRepo.orEmpty() + token = settings.updateToken.orEmpty() + homeSections = settings.homeSections.split(',') + cardDensity = settings.homeCardDensity + showCardMetadata = settings.showHomeCardMetadata + } + + val firstFocus = remember { FocusRequester() } + var shown by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + shown = true + // AnimatedVisibility does not attach its child until the following frame. Waiting + // for the entrance transition means focus moves from the slide to this panel + // reliably on every remote, rather than silently failing on an unattached node. + delay(170) + runCatching { firstFocus.requestFocus() } + } + + Box(modifier = modifier.fillMaxSize()) { + if (overlay) { + // Scrim dims the slide behind the panel, while leaving its artwork visible. + Box( + Modifier + .fillMaxSize() + .background(Color(0x99000000)) + .clickable( + interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, + indication = null, + onClick = onClose, + ) + ) + } else { + Box(Modifier.fillMaxSize().background(Color(0xFF0B0E11))) + } + + AnimatedVisibility( + visible = shown, + enter = slideInHorizontally(animationSpec = tween(150)) { it } + fadeIn(tween(150)), + modifier = Modifier.align(if (overlay) Alignment.CenterEnd else Alignment.Center), + ) { + Column( + modifier = Modifier + .width(if (overlay) 620.dp else 900.dp) + .fillMaxHeight() + .background(SettingsSurface) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 52.dp, vertical = 44.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Settings", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.weight(1f)) + Button(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = null) + } + } + + SectionLabel("APPEARANCE") + + TvSettingsRow( + onClick = { + showLogo = !showLogo + scope.launch { store.setShowTitleLogo(showLogo) } + }, + modifier = Modifier.fillMaxWidth().focusRequester(firstFocus), + title = "Title logos", + description = "Use Emby artwork when available", + value = if (showLogo) "On" else "Off", + ) + + Text("Progress ring colour", color = Faint, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + spinnerOptions.forEach { opt -> + Button( + onClick = { + ringColor = opt.hex + scope.launch { store.setRingColor(opt.hex) } + }, + ) { Text(if (ringColor.equals(opt.hex, ignoreCase = true)) "✓ ${opt.label}" else opt.label) } + } + } + + Divider() + SectionLabel("HOME SCREEN") + Text("Choose the rows shown on your home screen", color = Muted, fontSize = 14.sp) + listOf("continue" to "Continue watching", "favorites" to "Favorites", "latest" to "Latest movies").forEach { (key, label) -> + TvSettingsRow( + onClick = { + homeSections = if (key in homeSections) homeSections - key else homeSections + key + scope.launch { store.setHomeSections(homeSections) } + }, + title = label, + description = "Show this row on the home screen", + value = if (key in homeSections) "On" else "Off", + ) + } + Text("Card size", color = Faint, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + listOf("compact" to "Compact", "standard" to "Standard", "large" to "Large").forEach { (value, label) -> + Button(onClick = { + cardDensity = value + scope.launch { store.setHomeCardDensity(value) } + }) { Text(if (cardDensity == value) "✓ $label" else label) } + } + } + TvSettingsRow( + onClick = { + showCardMetadata = !showCardMetadata + scope.launch { store.setShowHomeCardMetadata(showCardMetadata) } + }, + title = "Card details", + description = "Show episode, runtime, and resume information", + value = if (showCardMetadata) "On" else "Off", + ) + + Divider() + SectionLabel("UPDATES") + + if (editableServer) { + SheetTextField( + label = "Gitea URL", + value = baseUrl, + onValueChange = { baseUrl = it; status = null }, + keyboardType = KeyboardType.Uri, + ) + SheetTextField( + label = "Repository (owner/repo)", + value = repoPath, + onValueChange = { repoPath = it; status = null }, + ) + SheetTextField( + label = "Access token", + value = token, + onValueChange = { token = it; status = null }, + isPassword = true, + ) + } else { + Text( + "Set the update server on the home screen to check for updates here.", + color = Muted, + fontSize = 13.sp, + ) + } + + TvSettingsRow( + onClick = { + if (!checking) { + checking = true + status = null + installMessage = null + scope.launch { + if (editableServer) store.setUpdateConfig(baseUrl, repoPath, token) + val s = settings + status = checker.check( + s.updateBaseUrl.orEmpty().ifEmpty { baseUrl }, + s.updateRepo.orEmpty().ifEmpty { repoPath }, + s.updateToken.orEmpty().ifEmpty { token }, + ) + checking = false + } + } + }, + title = "Check for updates", + description = "Installed version ${checker.installedVersion}", + value = if (checking) "Checking…" else "", + ) + + when (val s = status) { + is UpdateStatus.UpToDate -> + Text("You're on the latest version (${s.version}).", color = Color(0xFF7BD88F), fontSize = 14.sp) + is UpdateStatus.Error -> Text(s.message, color = Color(0xFFFF6B6B), fontSize = 14.sp) + is UpdateStatus.Available -> { + Text( + "Update available: ${s.version}", + color = Color(0xFF7BD88F), + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + ) + if (s.notes.isNotBlank()) Text(s.notes, color = Faint, fontSize = 13.sp) + Button( + onClick = { + installMessage = "Downloading update…" + scope.launch { + val result = checker.downloadAndInstall(s.apkUrl, token.ifEmpty { settings.updateToken.orEmpty() }) + result.exceptionOrNull()?.let { + installMessage = it.message + } ?: run { + installMessage = "Opening the installer…" + // The installer is now foreground. Stop an active Dream so + // it cannot retain a black system window during replacement. + onInstallerLaunched?.invoke() + } + } + }, + ) { Text("Download & install") } + } + null -> {} + } + installMessage?.let { Text(it, color = Faint, fontSize = 13.sp) } + + Divider() + SectionLabel("ABOUT") + Text( + "${stringResource(R.string.app_name)} ${checker.installedVersion} · by " + + stringResource(R.string.developer_name), + color = Muted, + fontSize = 13.sp, + ) + } + } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text(text, color = Muted, fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 2.sp) +} + +@Composable +private fun Divider() { + Box(Modifier.fillMaxWidth().height(1.dp).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f))) +} + +/** A restrained Android TV-style preference row with a clear remote-focus state. */ +@Composable +private fun TvSettingsRow( + title: String, + description: String, + value: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + var focused by remember { mutableStateOf(false) } + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(4.dp)) + .background(if (focused) FocusSurface else Color.Transparent) + .onFocusChanged { focused = it.isFocused } + .focusable() + .clickable(onClick = onClick) + .padding(horizontal = 22.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, color = Color.White, fontSize = 19.sp) + Text(description, color = Muted, fontSize = 14.sp) + } + if (value.isNotBlank()) Text(value, color = Color.White, fontSize = 17.sp) + } +} + +@Composable +private fun SheetTextField( + label: String, + value: String, + onValueChange: (String) -> Unit, + isPassword: Boolean = false, + keyboardType: KeyboardType = KeyboardType.Text, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(label, color = Muted, fontSize = 13.sp) + Box( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.22f), RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.background, RoundedCornerShape(8.dp)) + .padding(horizontal = 14.dp, vertical = 12.dp), + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = TextStyle(color = Color.White, fontSize = 18.sp), + cursorBrush = SolidColor(Color.White), + visualTransformation = if (isPassword) PasswordVisualTransformation() else VisualTransformation.None, + keyboardOptions = KeyboardOptions(keyboardType = if (isPassword) KeyboardType.Password else keyboardType), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt b/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt new file mode 100644 index 0000000..3004a9b --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/theme/Theme.kt @@ -0,0 +1,28 @@ +package com.ponzischeme89.memby.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.text.font.FontFamily +import android.graphics.Typeface +import androidx.tv.material3.LocalTextStyle +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.darkColorScheme + +private val EmbyColors = darkColorScheme( + primary = androidx.compose.ui.graphics.Color(0xFF52B54B), + onPrimary = androidx.compose.ui.graphics.Color.White, + surface = androidx.compose.ui.graphics.Color(0xFF101418), + background = androidx.compose.ui.graphics.Color(0xFF0B0E11), +) + +@Composable +fun MembyTheme(content: @Composable () -> Unit) { + // Android's native medium face is always available on TV, so it looks refined + // without a downloaded font or a first-render font swap. + val tvFont = FontFamily(Typeface.create("sans-serif-medium", Typeface.NORMAL)) + MaterialTheme(colorScheme = EmbyColors) { + CompositionLocalProvider(LocalTextStyle provides LocalTextStyle.current.copy(fontFamily = tvFont)) { + content() + } + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt b/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt new file mode 100644 index 0000000..abac2f8 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt @@ -0,0 +1,155 @@ +package com.ponzischeme89.memby.update + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.util.concurrent.TimeUnit + +/** Result of a "check for updates" against the configured Gitea release. */ +sealed interface UpdateStatus { + /** A newer release with a downloadable APK is available. */ + data class Available(val version: String, val apkUrl: String, val notes: String) : UpdateStatus + /** The latest release is not newer than what's installed. */ + data class UpToDate(val version: String) : UpdateStatus + /** Something went wrong; [message] is safe to show on screen. */ + data class Error(val message: String) : UpdateStatus +} + +/** + * Checks a Gitea repository's latest release for a newer APK and, when the user + * confirms, downloads it and hands it to the system package installer. + * + * Gitea exposes a GitHub-compatible API: GET /api/v1/repos/{owner}/{repo}/releases/latest. + * A personal access token is sent for private repos (both for the API call and the + * asset download). + */ +class UpdateChecker(private val context: Context) { + + private val json = Json { ignoreUnknownKeys = true } + + private val http = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + /** The installed versionName (e.g. "1.0"), or "?" if it can't be read. */ + val installedVersion: String + get() = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull() ?: "?" + + suspend fun check(baseUrl: String, repo: String, token: String): UpdateStatus = + withContext(Dispatchers.IO) { + val host = baseUrl.trim().trimEnd('/') + val repoPath = repo.trim().trim('/') + if (host.isEmpty() || repoPath.isEmpty()) { + return@withContext UpdateStatus.Error("Set the Gitea URL and repository first.") + } + val url = "$host/api/v1/repos/$repoPath/releases/latest" + + val release = runCatching { + val req = Request.Builder().url(url).apply { + if (token.isNotBlank()) header("Authorization", "token ${token.trim()}") + }.build() + http.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) { + return@withContext UpdateStatus.Error( + when (resp.code) { + 401, 403 -> "Update check unauthorized — check the access token." + 404 -> "No releases found at $repoPath." + else -> "Update server error (${resp.code})." + } + ) + } + json.decodeFromString(resp.body?.string().orEmpty()) + } + }.getOrElse { + return@withContext UpdateStatus.Error("Couldn't reach the update server.") + } + + val apk = release.assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) } + ?: return@withContext UpdateStatus.Error("Latest release has no APK attached.") + + val latest = release.tagName.ifBlank { release.name } + return@withContext if (isNewer(latest, installedVersion)) { + UpdateStatus.Available( + version = normalizeVersion(latest), + apkUrl = apk.browserDownloadUrl, + notes = release.body.trim(), + ) + } else { + UpdateStatus.UpToDate(installedVersion) + } + } + + /** + * Downloads the APK and launches the system installer. On Android O+ the app + * needs the "install unknown apps" permission; if it's missing we send the user + * to that settings screen and return a message asking them to retry. + */ + suspend fun downloadAndInstall(apkUrl: String, token: String): Result = + withContext(Dispatchers.IO) { + // Gate on the install-unknown-apps permission before spending a download. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !context.packageManager.canRequestPackageInstalls() + ) { + runCatching { + val intent = Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}"), + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + return@withContext Result.failure( + IllegalStateException("Allow Memby to install apps, then check again.") + ) + } + + runCatching { + val file = File(context.cacheDir, "memby-update.apk") + val req = Request.Builder().url(apkUrl).apply { + if (token.isNotBlank()) header("Authorization", "token ${token.trim()}") + }.build() + http.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) error("Download failed (${resp.code}).") + val body = resp.body ?: error("Empty download.") + file.outputStream().use { out -> body.byteStream().copyTo(out) } + } + + val uri = FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", file, + ) + val install = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(install) + } + } + + /** True when [remote] parses to a strictly higher version than [installed]. */ + private fun isNewer(remote: String, installed: String): Boolean { + val r = parseVersion(remote) + val i = parseVersion(installed) + for (k in 0 until maxOf(r.size, i.size)) { + val rv = r.getOrElse(k) { 0 } + val iv = i.getOrElse(k) { 0 } + if (rv != iv) return rv > iv + } + return false + } + + private fun parseVersion(v: String): List = + normalizeVersion(v).split('.', '-', ' ', '+').mapNotNull { it.toIntOrNull() } + + private fun normalizeVersion(v: String): String = v.trim().trimStart('v', 'V') +} diff --git a/app/src/main/java/com/ponzischeme89/memby/update/UpdateModels.kt b/app/src/main/java/com/ponzischeme89/memby/update/UpdateModels.kt new file mode 100644 index 0000000..321f44b --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/update/UpdateModels.kt @@ -0,0 +1,21 @@ +package com.ponzischeme89.memby.update + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Subset of a Gitea (GitHub-compatible) release we care about. */ +@Serializable +data class GiteaRelease( + @SerialName("tag_name") val tagName: String = "", + @SerialName("name") val name: String = "", + @SerialName("body") val body: String = "", + @SerialName("draft") val draft: Boolean = false, + @SerialName("prerelease") val prerelease: Boolean = false, + @SerialName("assets") val assets: List = emptyList(), +) + +@Serializable +data class GiteaAsset( + @SerialName("name") val name: String = "", + @SerialName("browser_download_url") val browserDownloadUrl: String = "", +) diff --git a/app/src/main/java/com/ponzischeme89/memby/update/UpdateRecoveryReceiver.kt b/app/src/main/java/com/ponzischeme89/memby/update/UpdateRecoveryReceiver.kt new file mode 100644 index 0000000..d31128c --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/update/UpdateRecoveryReceiver.kt @@ -0,0 +1,28 @@ +package com.ponzischeme89.memby.update + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.ponzischeme89.memby.ui.screensaver.ScreensaverActivity + +/** + * Reopens the launcher entry point when this package is replaced in place. + * + * Replacing an APK kills its process, including an active Dream's render process. On + * TV that can leave the old Dream surface black. Android sends this broadcast to the + * newly installed package, giving us a chance to present a real UI instead. + */ +class UpdateRecoveryReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return + + val launch = ScreensaverActivity.restartAfterUpdateIntent(context).apply { + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP, + ) + } + runCatching { context.startActivity(launch) } + } +} diff --git a/app/src/main/res/drawable/app_banner.xml b/app/src/main/res/drawable/app_banner.xml new file mode 100644 index 0000000..6357818 --- /dev/null +++ b/app/src/main/res/drawable/app_banner.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/drawable/emby_logo.png b/app/src/main/res/drawable/emby_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ca84b69649d0a5f81c73ec56a43a5c6cc7c56f0d GIT binary patch literal 869641 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

As`|3Y@=q6_-jYy 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 + Memby + Memby Screensaver + Memby movie & TV backdrops + ponzischeme89 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..05cabff --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/xml/emby_dream.xml b/app/src/main/res/xml/emby_dream.xml new file mode 100644 index 0000000..27c7f28 --- /dev/null +++ b/app/src/main/res/xml/emby_dream.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..6e6931a --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt new file mode 100644 index 0000000..e389676 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt @@ -0,0 +1,109 @@ +package com.ponzischeme89.memby.data + +import com.ponzischeme89.memby.data.model.GatewayHome +import com.ponzischeme89.memby.data.model.GatewayPlayback +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Contract tests for the gateway wire format. + * + * The fixtures below are the exact shape `server/internal/api` emits: camelCase envelope + * fields wrapping Emby's own PascalCase item JSON. If someone renames a field on either + * side, this fails before a TV ever sees it. + */ +class GatewayPayloadTest { + + private val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + isLenient = true + explicitNulls = false + } + + @Test + fun `decodes a home payload with emby-shaped items`() { + val payload = """ + { + "continueWatching": [ + {"Id":"1","Name":"Dune","Type":"Movie","UserData":{"PlaybackPositionTicks":36000000000}} + ], + "nextUp": [ + {"Id":"2","Name":"Pilot","Type":"Episode","SeriesName":"Severance"} + ], + "favorites": [ + {"Id":"3","Name":"Arrival","Type":"Movie","UserData":{"IsFavorite":true}} + ], + "latestMovies": [], + "partial": false + } + """.trimIndent() + + val home = json.decodeFromString(payload) + + assertEquals("Dune", home.continueWatching.single().name) + assertEquals(3_600_000L, home.continueWatching.single().userData!!.playbackPositionTicks / 10_000L) + assertEquals("Severance", home.nextUp.single().seriesName) + assertTrue(home.favorites.single().isFavorite) + assertTrue(home.latestMovies.isEmpty()) + assertEquals(false, home.partial) + } + + @Test + fun `decodes server-composed rows including recommendations`() { + val payload = """ + { + "rows": [ + {"id":"continue","title":"Continue Watching","kind":"continue","items":[{"Id":"1","Name":"Dune","Type":"Movie"}]}, + {"id":"favorites","title":"Favourites","kind":"favorites","items":[{"Id":"3","Name":"Arrival","Type":"Movie"}]}, + {"id":"similar:sev","title":"Because you watched Severance","kind":"similar","items":[{"Id":"7","Name":"Devs","Type":"Series"}]}, + {"id":"recommended","title":"Recommended from your watching history","kind":"recommended","items":[{"Id":"8","Name":"Solaris","Type":"Movie"}]} + ], + "continueWatching": [{"Id":"1","Name":"Dune","Type":"Movie"}], + "nextUp": [], + "favorites": [{"Id":"3","Name":"Arrival","Type":"Movie"}], + "latestMovies": [], + "partial": false + } + """.trimIndent() + + val home = json.decodeFromString(payload) + + assertEquals( + listOf("continue", "favorites", "similar:sev", "recommended"), + home.rows.map { it.id }, + ) + assertEquals("Because you watched Severance", home.rows[2].title) + assertEquals("Solaris", home.rows.last().items.single().name) + } + + @Test + fun `a home payload without rows still decodes`() { + // The gateway omits recommendation rows while they are still building, and an + // older gateway would not send `rows` at all. + val home = json.decodeFromString( + """{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":false}""", + ) + assertTrue(home.rows.isEmpty()) + } + + @Test + fun `a partial home payload still decodes`() { + val home = json.decodeFromString( + """{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":true}""", + ) + assertTrue(home.partial) + } + + @Test + fun `decodes a playback response`() { + val playback = json.decodeFromString( + """{"itemId":"9","title":"Severance – Pilot","url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""", + ) + assertEquals("9", playback.itemId) + assertEquals(42_000L, playback.resumePositionMs) + assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream")) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/data/MaintenanceMessageTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/MaintenanceMessageTest.kt new file mode 100644 index 0000000..a3f371e --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/MaintenanceMessageTest.kt @@ -0,0 +1,55 @@ +package com.ponzischeme89.memby.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * The gateway's 503 body is the only thing allowed to put words on the maintenance + * screen, so what gets trusted out of it matters. + */ +class MaintenanceMessageTest { + + @Test + fun `reads the operator's message`() { + val body = """{"error":"Back at 9pm","maintenance":true,"message":"Back at 9pm"}""" + + assertEquals("Back at 9pm", parseMaintenanceMessage(body)) + } + + @Test + fun `ignores a 503 that is not a maintenance response`() { + // Some proxy or upstream returning its own 503 must not get to write on screen. + assertNull(parseMaintenanceMessage("""{"message":"upstream connect error"}""")) + assertNull(parseMaintenanceMessage("""{"maintenance":false,"message":"nope"}""")) + } + + @Test + fun `survives a body that is not the shape we expect`() { + assertNull(parseMaintenanceMessage("")) + assertNull(parseMaintenanceMessage(" ")) + assertNull(parseMaintenanceMessage("502 Bad Gateway")) + assertNull(parseMaintenanceMessage("""{"maintenance":true""")) + } + + @Test + fun `blank and whitespace-only messages are rejected`() { + assertNull(parseMaintenanceMessage("""{"maintenance":true,"message":" "}""")) + assertNull(parseMaintenanceMessage("""{"maintenance":true}""")) + } + + @Test + fun `an over-long message is truncated to something that fits a screen`() { + val long = "x".repeat(400) + val parsed = parseMaintenanceMessage("""{"maintenance":true,"message":"$long"}""") + + assertEquals(160, parsed?.length) + } + + @Test + fun `unknown fields from a newer gateway are ignored`() { + val body = """{"maintenance":true,"message":"Upgrading","until":"2026-07-27T22:00:00Z"}""" + + assertEquals("Upgrading", parseMaintenanceMessage(body)) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt new file mode 100644 index 0000000..dae3f37 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/PlaybackReportMathTest.kt @@ -0,0 +1,16 @@ +package com.ponzischeme89.memby.data + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PlaybackReportMathTest { + @Test + fun millisecondsAreConvertedToEmbyTicks() { + assertEquals(12_340_000L, millisecondsToTicks(1_234L)) + } + + @Test + fun negativePositionsAreClamped() { + assertEquals(0L, millisecondsToTicks(-1L)) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/data/ProfileSettingsTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/ProfileSettingsTest.kt new file mode 100644 index 0000000..b514643 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/ProfileSettingsTest.kt @@ -0,0 +1,39 @@ +package com.ponzischeme89.memby.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ProfileSettingsTest { + private val profile = EmbyProfile( + id = "server::user", + serverUrl = "http://emby", + token = "token", + userId = "user", + username = "Matt", + ) + + @Test + fun `active profile matches both server and user`() { + val settings = Settings( + serverUrl = profile.serverUrl, + token = profile.token, + userId = profile.userId, + profiles = listOf(profile), + ) + + assertEquals(profile.id, settings.activeProfileId) + } + + @Test + fun `profile from another server is not treated as active`() { + val settings = Settings( + serverUrl = "http://another-server", + token = profile.token, + userId = profile.userId, + profiles = listOf(profile), + ) + + assertNull(settings.activeProfileId) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/data/RowAnalyticsTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/RowAnalyticsTest.kt new file mode 100644 index 0000000..3ef79c9 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/RowAnalyticsTest.kt @@ -0,0 +1,141 @@ +package com.ponzischeme89.memby.data + +import com.ponzischeme89.memby.data.analytics.RowAnalytics +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RowAnalyticsTest { + + /** A controllable clock, so dwell assertions are exact rather than timing-dependent. */ + private class FakeClock(var millis: Long = 1_700_000_000_000) { + fun advance(by: Long) { millis += by } + } + + private fun analytics(clock: FakeClock, maxBuffered: Int = 200) = + RowAnalytics(now = { clock.millis }, maxBuffered = maxBuffered) + + @Test + fun `dwell is measured when focus leaves a row`() { + val clock = FakeClock() + val collector = analytics(clock) + + collector.rowFocused("recommended", "MOVIES", "item-1") + clock.advance(5_000) + collector.rowFocused("favorites", "FAVORITES", "item-2") + + val focus = collector.drain().single { it.event == RowAnalytics.EVENT_FOCUS } + assertEquals("recommended", focus.rowId) + assertEquals(5_000L, focus.dwellMs) + assertEquals("item-1", focus.itemId) + } + + @Test + fun `moving between cards inside one row keeps measuring the same dwell`() { + val clock = FakeClock() + val collector = analytics(clock) + + collector.rowFocused("recommended", "MOVIES", "item-1") + clock.advance(3_000) + collector.rowFocused("recommended", "MOVIES", "item-2") + clock.advance(3_000) + collector.endFocus() + + val focuses = collector.drain().filter { it.event == RowAnalytics.EVENT_FOCUS } + assertEquals(1, focuses.size) + assertEquals(6_000L, focuses.single().dwellMs) + } + + @Test + fun `passing through a row is not counted as attention`() { + val clock = FakeClock() + val collector = analytics(clock) + + collector.rowFocused("continue", "CONTINUE", "a") + clock.advance(RowAnalytics.MIN_DWELL_MS - 1) + collector.rowFocused("favorites", "FAVORITES", "b") + + assertTrue( + "a sub-threshold glance should produce no focus event", + collector.drain().none { it.event == RowAnalytics.EVENT_FOCUS }, + ) + } + + @Test + fun `an impression is recorded once per row`() { + val collector = analytics(FakeClock()) + + collector.rowImpression("favorites", "FAVORITES") + collector.rowImpression("favorites", "FAVORITES") + collector.rowImpression("recommended", "MOVIES") + + val impressions = collector.drain().filter { it.event == RowAnalytics.EVENT_IMPRESSION } + assertEquals(listOf("favorites", "recommended"), impressions.map { it.rowId }) + } + + @Test + fun `focusing a row that was never reported still records the impression`() { + val collector = analytics(FakeClock()) + + collector.rowFocused("similar:sev", "MOVIES", "item-1") + + val events = collector.drain() + assertEquals(1, events.count { it.event == RowAnalytics.EVENT_IMPRESSION }) + assertEquals("similar:sev", events.single().rowId) + } + + @Test + fun `selections are recorded with their item`() { + val collector = analytics(FakeClock()) + + collector.rowSelected("recommended", "MOVIES", "item-9") + + val select = collector.drain().single() + assertEquals(RowAnalytics.EVENT_SELECT, select.event) + assertEquals("item-9", select.itemId) + assertEquals("MOVIES", select.rowKind) + } + + @Test + fun `draining clears the buffer`() { + val collector = analytics(FakeClock()) + collector.rowImpression("favorites", "FAVORITES") + + assertEquals(1, collector.drain().size) + assertEquals(0, collector.drain().size) + } + + @Test + fun `the buffer is bounded and keeps the most recent events`() { + val collector = analytics(FakeClock(), maxBuffered = 3) + + repeat(6) { collector.rowImpression("row-$it", "MOVIES") } + + val events = collector.drain() + assertEquals(3, events.size) + assertEquals(listOf("row-3", "row-4", "row-5"), events.map { it.rowId }) + } + + @Test + fun `timestamps are sent in the format the gateway parses`() { + val collector = analytics(FakeClock()) + collector.rowImpression("favorites", "FAVORITES") + + val occurredAt = collector.drain().single().occurredAt + assertTrue( + "expected RFC3339 UTC, got $occurredAt", + occurredAt.matches(Regex("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z""")), + ) + } + + @Test + fun `reset forgets buffered events and seen rows`() { + val collector = analytics(FakeClock()) + collector.rowImpression("favorites", "FAVORITES") + + collector.reset() + collector.rowImpression("favorites", "FAVORITES") + + assertEquals(1, collector.drain().size) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/data/ServerConfigTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/ServerConfigTest.kt new file mode 100644 index 0000000..69d9ab4 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/ServerConfigTest.kt @@ -0,0 +1,31 @@ +package com.ponzischeme89.memby.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ServerConfigTest { + @Test + fun `hardwired address wins over anything the user typed`() { + assertEquals( + "http://tv.example.com:8096", + resolveServerUrl("http://tv.example.com:8096/", "http://192.168.1.50:8096"), + ) + } + + @Test + fun `hardwired address is normalised like a typed one`() { + assertEquals("http://10.0.0.5:8096", resolveServerUrl("10.0.0.5:8096", "")) + } + + @Test + fun `falls back to the typed address when the build pins nothing`() { + assertEquals("https://emby.example.com", resolveServerUrl(null, "https://emby.example.com/")) + assertEquals("https://emby.example.com", resolveServerUrl(" ", "https://emby.example.com/")) + } + + @Test + fun `returns null when neither source supplies an address`() { + assertNull(resolveServerUrl(null, "")) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeUiStateTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeUiStateTest.kt new file mode 100644 index 0000000..a92c2e3 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeUiStateTest.kt @@ -0,0 +1,39 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.data.HomeCache +import com.ponzischeme89.memby.data.model.BaseItem +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class HomeUiStateTest { + @Test + fun combinedWatchingRowPreservesOrderAndRemovesDuplicates() { + val resumable = BaseItem(id = "resume", name = "Resume") + val duplicate = BaseItem(id = "same", name = "Resume copy") + val next = BaseItem(id = "next", name = "Next") + + val state = HomeUiState( + continueWatching = listOf(resumable, duplicate), + nextUp = listOf(duplicate.copy(name = "Next copy"), next), + ) + + assertEquals(listOf("resume", "same", "next"), state.watchingAndNextUp.map { it.id }) + } + + @Test + fun cachedContentIsShownWhileOnlyMissingRowsLoad() { + val cache = HomeCache( + continueWatching = listOf(BaseItem(id = "resume")), + favorites = listOf(BaseItem(id = "favorite")), + ) + + val state = HomeUiState.from(cache) + + assertFalse(HomeSection.CONTINUE in state.loading) + assertTrue(HomeSection.NEXT_UP in state.loading) + assertFalse(HomeSection.FAVORITES in state.loading) + assertTrue(HomeSection.LATEST in state.loading) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt new file mode 100644 index 0000000..793d3bd --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt @@ -0,0 +1,35 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.MediaStream +import org.junit.Assert.assertEquals +import org.junit.Test + +class MediaBadgesTest { + @Test + fun `derives premium video and audio badges without duplicates`() { + val item = BaseItem( + id = "movie", + mediaStreams = listOf( + MediaStream( + type = "Video", + codec = "hevc", + width = 3840, + videoRangeType = "DOVI", + title = "Dolby Vision HEVC", + ), + MediaStream(type = "Audio", title = "TrueHD Dolby Atmos"), + ), + ) + + assertEquals( + listOf("4K", "DOLBY VISION", "HEVC", "DOLBY ATMOS"), + mediaBadges(item), + ) + } + + @Test + fun `returns no badges when stream metadata is unavailable`() { + assertEquals(emptyList(), mediaBadges(BaseItem(id = "unknown"))) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt new file mode 100644 index 0000000..b459630 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt @@ -0,0 +1,128 @@ +package com.ponzischeme89.memby.ui + +import com.ponzischeme89.memby.data.HomeCache +import com.ponzischeme89.memby.data.Settings +import com.ponzischeme89.memby.data.model.BaseItem +import com.ponzischeme89.memby.data.model.HomeRow +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The home screen is composed by the gateway. These pin the parts of that contract the + * client is responsible for: honouring the user's section toggles, always showing rows + * the server invented, and surviving a cold start from cache. + */ +class ServerHomeRowsTest { + + private fun row(id: String, kind: String, vararg itemIds: String) = HomeRow( + id = id, + title = id.replaceFirstChar(Char::uppercase), + kind = kind, + items = itemIds.map { BaseItem(id = it) }, + ) + + private val serverRows = listOf( + row("continue", "continue", "a"), + row("next-up", "nextup", "b"), + row("favorites", "favorites", "c"), + row("latest-movies", "latest", "d"), + row("similar:sev", "similar", "e"), + row("recommended", "recommended", "f"), + ) + + @Test + fun `server row order and titles are preserved`() { + val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), Settings()) + + assertEquals( + listOf("continue", "next-up", "favorites", "latest-movies", "similar:sev", "recommended"), + rows.map { it.id }, + ) + assertEquals("Recommended", rows.last().title) + } + + @Test + fun `disabling a section hides its rows but never the recommendations`() { + val settings = Settings(homeSections = "continue") + + val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), settings) + + assertEquals( + listOf("continue", "next-up", "similar:sev", "recommended"), + rows.map { it.id }, + ) + } + + @Test + fun `recommendation rows render as poster cards`() { + val rows = serverHomeRows(HomeUiState(rows = serverRows, loading = emptySet()), Settings()) + .associateBy { it.id } + + assertEquals(MediaRowKind.CONTINUE, rows.getValue("continue").kind) + assertEquals(MediaRowKind.NEXT_UP, rows.getValue("next-up").kind) + assertEquals(MediaRowKind.FAVORITES, rows.getValue("favorites").kind) + assertEquals(MediaRowKind.MOVIES, rows.getValue("recommended").kind) + assertEquals(MediaRowKind.MOVIES, rows.getValue("similar:sev").kind) + } + + @Test + fun `an unknown row kind from a newer server still renders`() { + val rows = serverHomeRows( + HomeUiState(rows = listOf(row("collection:halloween", "collection", "x")), loading = emptySet()), + Settings(), + ) + + assertEquals(1, rows.size) + assertEquals(MediaRowKind.MOVIES, rows.single().kind) + } + + @Test + fun `only empty rows show a loading state while a refresh is running`() { + val rows = serverHomeRows( + HomeUiState( + rows = listOf(row("continue", "continue", "a"), row("recommended", "recommended")), + loading = setOf(HomeSection.CONTINUE), + ), + Settings(), + ).associateBy { it.id } + + assertEquals(false, rows.getValue("continue").loading) + assertEquals(true, rows.getValue("recommended").loading) + } + + @Test + fun `rows survive a round trip through the on-device cache`() { + val state = HomeUiState(rows = serverRows, loading = emptySet()) + + val encoded = Json.encodeToString(HomeCache.serializer(), state.toCache()) + val restored = HomeUiState.from(Json.decodeFromString(encoded)) + + assertEquals(serverRows.map { it.id }, restored.rows.map { it.id }) + assertEquals("f", restored.rows.last().items.single().id) + } + + @Test + fun `maintenance is a distinct state from an ordinary refresh error`() { + val offline = HomeUiState(rows = serverRows, maintenanceMessage = "Back at 9pm", hasRefreshError = true) + val slow = HomeUiState(rows = serverRows, hasRefreshError = true) + + // The screen keys off maintenanceMessage; a slow connection must not trigger it. + assertEquals("Back at 9pm", offline.maintenanceMessage) + assertEquals(null, slow.maintenanceMessage) + + // Rows survive underneath, so returning from maintenance does not start empty. + assertEquals(serverRows.size, serverHomeRows(offline, Settings()).size) + } + + @Test + fun `a cache written before rows existed still decodes`() { + val legacy = """{"continueWatching":[{"Id":"a"}],"favorites":[],"nextUp":[],"latestMovies":[]}""" + + val restored = HomeUiState.from(Json { ignoreUnknownKeys = true }.decodeFromString(legacy)) + + assertTrue(restored.rows.isEmpty()) + assertEquals("a", restored.continueWatching.single().id) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/screensaver/SlideProgressMathTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/screensaver/SlideProgressMathTest.kt new file mode 100644 index 0000000..c9d08e3 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/screensaver/SlideProgressMathTest.kt @@ -0,0 +1,39 @@ +package com.ponzischeme89.memby.ui.screensaver + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for the ring's pure geometry. The animation/lifecycle behaviour (progress + * reaching 100% after the slide duration, advancing exactly once, resetting on the next + * slide, restarting on manual navigation, and cancelling on teardown) is driven by + * Compose's [androidx.compose.animation.core.Animatable] + `repeatOnLifecycle` and is + * verified on-device against the DreamService path rather than here, since this module + * has no Compose UI-test / Robolectric harness. + */ +class SlideProgressMathTest { + + private val tolerance = 0.0001f + + @Test + fun beginsEmpty() { + assertEquals(0f, SlideProgressMath.sweepAngle(0f), tolerance) + } + + @Test + fun reachesFullCircleAtCompletion() { + assertEquals(360f, SlideProgressMath.sweepAngle(1f), tolerance) + } + + @Test + fun isProportionalMidway() { + assertEquals(180f, SlideProgressMath.sweepAngle(0.5f), tolerance) + assertEquals(90f, SlideProgressMath.sweepAngle(0.25f), tolerance) + } + + @Test + fun clampsOutOfRangeValues() { + assertEquals(0f, SlideProgressMath.sweepAngle(-0.5f), tolerance) + assertEquals(360f, SlideProgressMath.sweepAngle(1.5f), tolerance) + } +} diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts new file mode 100644 index 0000000..d2ecccc --- /dev/null +++ b/benchmark/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.test") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.ponzischeme89.memby.benchmark" + compileSdk = 35 + + defaultConfig { + minSdk = 23 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["androidx.benchmark.enabledRules"] = "Macrobenchmark" + // The target is the current debug build for Phase 1 only. Results are labelled + // debug-influenced; a release benchmark variant will be added before Phase 2. + testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "DEBUGGABLE" + } + targetProjectPath = ":app" + experimentalProperties["android.experimental.self-instrumenting"] = true + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildTypes { + create("release") { + initWith(getByName("debug")) + isDebuggable = false + } + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } +} + +dependencies { + implementation("androidx.benchmark:benchmark-macro-junit4:1.2.4") + implementation("androidx.test.ext:junit:1.2.1") + implementation("androidx.test:runner:1.6.2") +} diff --git a/benchmark/src/main/AndroidManifest.xml b/benchmark/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3ceaf1a --- /dev/null +++ b/benchmark/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt b/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt new file mode 100644 index 0000000..5cdce64 --- /dev/null +++ b/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt @@ -0,0 +1,47 @@ +package com.ponzischeme89.memby.benchmark + +import androidx.benchmark.macro.CompilationMode +import androidx.benchmark.macro.FrameTimingMetric +import androidx.benchmark.macro.MacrobenchmarkScope +import androidx.benchmark.macro.StartupMode +import androidx.benchmark.macro.StartupTimingMetric +import androidx.benchmark.macro.junit4.MacrobenchmarkRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class HomeBenchmark { + @get:Rule val benchmarkRule = MacrobenchmarkRule() + + @Test + fun coldStartToHome() = benchmarkRule.measureRepeated( + packageName = "com.ponzischeme89.memby", + metrics = listOf(StartupTimingMetric()), + compilationMode = CompilationMode.None(), + startupMode = StartupMode.COLD, + iterations = 3, + setupBlock = { pressHome() }, + measureBlock = { startActivityAndWait() }, + ) + + @Test + fun homeDpadAndRows() = benchmarkRule.measureRepeated( + packageName = "com.ponzischeme89.memby", + metrics = listOf(FrameTimingMetric()), + compilationMode = CompilationMode.None(), + startupMode = StartupMode.WARM, + iterations = 3, + setupBlock = { startActivityAndWait() }, + measureBlock = { exerciseHome() }, + ) + + private fun MacrobenchmarkScope.exerciseHome() { + device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) + device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) + device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_DOWN) + device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) + device.waitForIdle() + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a2db97f --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,8 @@ +// Top-level build file. Plugin versions are declared here and applied per-module. +plugins { + id("com.android.application") version "8.13.2" apply false + id("com.android.test") version "8.13.2" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false +} diff --git a/deploy-debug.ps1 b/deploy-debug.ps1 new file mode 100644 index 0000000..54003c3 --- /dev/null +++ b/deploy-debug.ps1 @@ -0,0 +1,24 @@ +param( + [string] $Serial +) + +$ErrorActionPreference = 'Stop' +$packageName = 'com.ponzischeme89.memby' +$activityName = "$packageName/$packageName.ui.MainActivity" +$jdkHome = 'C:\Program Files\Android\Android Studio\jbr' +$adbPath = Join-Path $env:LOCALAPPDATA 'Android\Sdk\platform-tools\adb.exe' + +if (Test-Path $jdkHome) { $env:JAVA_HOME = $jdkHome } +if (-not (Test-Path $adbPath)) { throw "Android Debug Bridge was not found at $adbPath" } + +$deviceArgs = if ($Serial) { @('-s', $Serial) } else { @() } +# Stop only Memby before replacing its debug APK. +& $adbPath @deviceArgs shell am force-stop $packageName + +& .\gradlew.bat installDebug +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Start the new client's launcher after installation. +& $adbPath @deviceArgs shell input keyevent KEYCODE_WAKEUP +& $adbPath @deviceArgs shell am start -W -n $activityName +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ddce00e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +name: memby + +services: + server: + build: ./server + restart: unless-stopped + ports: + - "${MEMBY_PORT:-8080}:8080" + environment: + MEMBY_LISTEN_ADDR: ":8080" + # How the gateway reaches Emby. + MEMBY_EMBY_URL: "${MEMBY_EMBY_URL:?set MEMBY_EMBY_URL in .env}" + # What the TVs are told to stream from. Only set this when it differs from the + # address above (video goes device -> Emby directly, never through the gateway). + MEMBY_EMBY_PUBLIC_URL: "${MEMBY_EMBY_PUBLIC_URL:-}" + MEMBY_DATABASE_URL: "postgres://memby:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/memby?sslmode=disable" + MEMBY_REDIS_URL: "redis://redis:6379/0" + MEMBY_HOME_TTL: "${MEMBY_HOME_TTL:-60s}" + # Unset disables /admin entirely — the library import, maintenance switch and + # analytics page all live behind it. + MEMBY_ADMIN_TOKEN: "${MEMBY_ADMIN_TOKEN:-}" + # Hourly incremental import: enough for episodes landing through the day, and + # films appearing weekly ride along. + MEMBY_SYNC_INTERVAL: "${MEMBY_SYNC_INTERVAL:-1h}" + MEMBY_SYNC_ON_START: "${MEMBY_SYNC_ON_START:-false}" + MEMBY_SYNC_USER_ID: "${MEMBY_SYNC_USER_ID:-}" + MEMBY_SYNC_API_KEY: "${MEMBY_SYNC_API_KEY:-}" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + # No shell or curl in a distroless image, so probe with the binary's own server + # via the container's TCP port from the Docker healthcheck's perspective. + test: ["CMD", "/app/memby-server", "-healthcheck"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + + postgres: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_USER: memby + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}" + POSTGRES_DB: memby + volumes: + - memby-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U memby -d memby"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + memby-postgres: diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..0b5cafc --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true + +android.useAndroidX=true +android.nonTransitiveRClass=true + +kotlin.code.style=official + +# The Emby server every build of Memby signs in to. Users never see or type it. +# Leave blank to fall back to asking for the address on the setup screen. +# Override per-machine in ~/.gradle/gradle.properties, or per-build with +# .\gradlew.bat assembleDebug -Pmemby.serverUrl=http://10.0.0.5:8096 +memby.serverUrl=https://molise.bounceme.net + +# The Memby gateway container (server/). When set, the app talks to it instead of Emby +# and the address above is only used as the fallback path. Blank = talk to Emby directly. +memby.gatewayUrl=https://memby.bounceme.net + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..d95bf61 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..640d686 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..7ff84d2 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,3 @@ +.git +*.md +bin/ diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..6762777 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.26-alpine AS build +WORKDIR /src + +# Dependencies first so edits to the source don't re-download the module cache. +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \ + -ldflags="-s -w" -o /out/memby-server ./cmd/memby-server + +# distroless/static carries CA certificates, which the gateway needs to reach an +# HTTPS Emby server, and runs as a non-root user by default. +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /app +COPY --from=build /out/memby-server /app/memby-server +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/app/memby-server"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..f3d7961 --- /dev/null +++ b/server/README.md @@ -0,0 +1,246 @@ +# Memby gateway + +A small Go service that sits between the Memby Android TV client and Emby. It owns +authentication, caching, search and the shaping of TV screens, so the client can stay a +thin renderer. + +Video never passes through here. `/v1/items/{id}/playback` returns a direct-play URL +pointing at Emby itself; only metadata and artwork traverse the gateway. + +## Run it + +```bash +cp .env.example .env # from the repo root +$EDITOR .env # set MEMBY_EMBY_URL and POSTGRES_PASSWORD +docker compose up -d --build +curl localhost:8080/readyz +``` + +Then build the TV app against it: + +```powershell +.\gradlew.bat assembleDebug -Pmemby.gatewayUrl=http://:8080 +``` + +Leaving `memby.gatewayUrl` blank keeps the app on its original direct-to-Emby path, so a +gateway outage is one rebuild away from being routed around. + +## Local development + +```bash +go build ./... +go test ./... +go run ./cmd/memby-server # needs Postgres + Redis reachable +``` + +On Windows, `go` may need `-buildvcs=false` when the working tree has no usable `.git`. + +## API + +All `/v1` routes need `Authorization: Bearer ` from `/v1/auth/login`. Image URLs +accept `?t=` instead, because the client's image loader fetches plain URLs with no +headers attached. + +| Method | Path | Purpose | +| --- | --- | --- | +| POST | `/v1/auth/login` | Emby credentials in, gateway token out | +| POST | `/v1/auth/logout` | Retire this device's token | +| GET | `/v1/auth/session` | Confirm a stored token is still valid | +| GET | `/v1/home?limit=` | **Every launcher row in one response** | +| GET | `/v1/recommendations?refresh=1` | Recommendation rows alone; `refresh` forces a rebuild | +| GET | `/v1/screensaver?limit=` | Backdrop pool, cached and shuffled per request | +| GET | `/v1/search?q=&limit=` | Library search | +| GET | `/v1/items/{id}` | Full metadata for one item | +| GET | `/v1/items/{id}/playback` | Resolves series → episode, returns a direct-play URL | +| GET | `/v1/items/{id}/trailer` | First local trailer, or 404 | +| POST | `/v1/items/{id}/favorite` | `{"value":true}` | +| POST | `/v1/items/{id}/played` | `{"value":true}` | +| POST | `/v1/playback/{started\|progress\|stopped}` | Progress reporting | +| POST | `/v1/analytics/rows` | Batched row engagement from a TV | +| GET | `/v1/images/{itemId}/{backdrop\|primary\|logo\|thumb}` | Artwork proxy | +| GET | `/healthz`, `/readyz` | Liveness, readiness | + +### Server-driven rows + +`/v1/home` returns a `rows` array — order, titles and kinds all decided here — plus the +four fixed rows repeated flat for the client's offline cache: + +```json +{ + "rows": [ + {"id": "continue", "title": "Continue Watching", "kind": "continue", "items": [...]}, + {"id": "next-up", "title": "Next Up", "kind": "nextup", "items": [...]}, + {"id": "favorites", "title": "Favourites", "kind": "favorites", "items": [...]}, + {"id": "latest-movies", "title": "Recently Added Movies", "kind": "latest", "items": [...]}, + {"id": "similar:sev", "title": "Because you watched Severance", "kind": "similar", "items": [...]}, + {"id": "recommended", "title": "Recommended from your watching history", "kind": "recommended", "items": [...]} + ], + "continueWatching": [...], "nextUp": [...], "favorites": [...], "latestMovies": [...], + "partial": false +} +``` + +The TV renders whatever arrives, so a new row ships without an app release. `kind` picks +the card shape; an unrecognised kind falls back to poster cards rather than being dropped. +The user's own section toggles still hide the four fixed rows, but never rows the server +invented — nobody opted out of a row that did not exist when they last opened Settings. + +### Recommendations + +`internal/recommend` builds rows from viewing history. Two kinds: + +- **"Because you watched X"** — Emby's own `/Items/{id}/Similar` for the most recent + distinct titles, filtered down to what the user has not seen. Emby's similarity ranking + is better than anything worth reimplementing here; this only removes the already-watched. +- **"Recommended from your watching history"** — genre and studio affinity. History is + weighted by recency (0.94 per position, so the 12th item counts about half the most + recent), favourites add a smaller fixed weight, and candidates are unplayed titles in the + top three genres scored by affinity + a mild community-rating nudge. Titles tagged with + many genres get a `sqrt(n)` penalty so genre-stuffing cannot buy a top slot. + +Rows shorter than four items are dropped, and a user with no history gets no rows at all +rather than a strip of noise. + +**The home screen never waits on the engine.** Rows live in their own `r::rows` +cache key with a long TTL (2h). A cache miss serves home immediately without them and +triggers a background rebuild — deduplicated per user, so four TVs waking together do the +work once. Because the key sits outside the `u:` namespace, a favourite toggle does not +throw the recommendations away; only a finished playback does, since that is the one event +that genuinely changes viewing history. + +The scoring is pure and unit-tested (`profile_test.go`), and the row assembly runs against +a fake Emby (`engine_test.go`), so neither needs a server to verify. + +Item payloads are Emby's own JSON, forwarded verbatim. That is deliberate: the Android +client already models this shape, so there is no second schema to keep in sync. +`app/src/test/.../GatewayPayloadTest.kt` and `internal/api/api_test.go` pin the envelope +around it from both sides. + +## Admin interface + +`http://:8080/admin/` — a single self-contained page for library imports, the +maintenance switch and row engagement. Set `MEMBY_ADMIN_TOKEN` to enable it; unset, every +`/admin` route 404s so it cannot be left exposed by accident. Paste the token into the +field at the top of the page; it is kept in the browser's local storage and sent as a +bearer header. Put the whole path behind your reverse proxy's own auth as well if the +gateway is reachable from outside the LAN. + +| Method | Path | Purpose | +| --- | --- | --- | +| GET | `/admin/` | The page | +| GET | `/admin/api/status` | Library counts, sync history, maintenance state | +| POST | `/admin/api/sync` | `{"kind":"full"}` or `{"kind":"incremental"}` | +| POST | `/admin/api/maintenance` | `{"enabled":true,"message":"…"}` | +| GET | `/admin/api/analytics?days=7` | Row engagement | + +## Library import + +`internal/library` copies Emby's catalogue into Postgres so the gateway answers from its +own data instead of asking Emby per request. + +- **Full** — pages through everything (500 items per request), then deletes any row it did + not touch, which is how removals propagate. Run once to seed; re-run after reorganising + the library. +- **Incremental** — asks Emby only for items changed since the last successful run + (`MinDateLastSaved`, with a minute of overlap so nothing falls between runs). This is + the hourly job: new episodes appear within the hour, and the weekly film drop rides + along with no extra configuration. + +An incremental run with no previous success upgrades itself to a full one, so a fresh +deployment self-seeds on its first tick. Only one import runs at a time; the scheduler +skips its tick if one is still going, and interrupted runs are marked failed at boot +rather than sitting on "running" forever. + +**Credentials.** Imports use `MEMBY_SYNC_USER_ID` + `MEMBY_SYNC_API_KEY` when set, and +otherwise borrow the most recently active TV session. The fallback means a new deployment +imports as soon as somebody signs in, but it stops working if that user is deleted — set a +service account for anything long-lived. + +**What is *not* imported:** every query runs with `EnableUserData=false`. Watched flags, +favourites and resume positions are per-user and cannot be shared across a household, so +they still come from Emby live. The imported copy powers search and the recommendation +candidate pool. + +## Maintenance mode + +Takes Memby down independently of Emby: all `/v1` routes answer `503` with +`{"maintenance": true, "message": "…"}`, and the TV shows the operator's message instead of +a network error. `/healthz`, `/readyz` and `/admin` stay up — they are what you need while +the app is deliberately off. + +The switch lives in Postgres, not memory, so a restart cannot quietly bring the app back +up mid-repair. Each instance caches it and re-reads every 30 seconds, so toggling it +directly in the database works too. + +## Row analytics + +The TV reports three signals per row — `impression` (drawn), `focus` (the remote landed +there, with dwell), `select` (something was opened) — batched and uploaded every 20 +seconds to `POST /v1/analytics/rows`. Dwell below 400 ms is dropped client-side as D-pad +travel rather than attention, and the server clamps anything over 30 minutes. + +Read it at `/admin/`, sorted by dwell. Dwell is the number worth watching: impressions +only say a row was on screen, while dwell says someone stopped there. It is the fastest +way to tell whether "Recommended from your watching history" is earning its slot. + +Raw events are pruned after `MEMBY_ANALYTICS_RETENTION` (90 days) and aggregates are +computed at read time, so nothing survives the prune. This is tuning telemetry, not a +record of what anyone watched. + +## Caching + +Redis holds everything user-scoped under `u::*`, plus session lookups under +`sess:` and recommendation rows under `r::rows`. Any mutation — +favourite, watched, playback stopped — drops the `u:` keys, so the next home request +re-reads Emby rather than serving a row it just contradicted. Partial home payloads are +served but never cached. The `r:` namespace is deliberately excluded from that wipe (see +Recommendations above). + +Postgres holds only sessions. It is the durable half: losing Redis costs a cold cache, +losing Postgres signs everyone out. + +## Configuration + +| Variable | Default | Notes | +| --- | --- | --- | +| `MEMBY_EMBY_URL` | *required* | How the gateway reaches Emby | +| `MEMBY_EMBY_PUBLIC_URL` | = `MEMBY_EMBY_URL` | What TVs stream from | +| `MEMBY_DATABASE_URL` | *required* | Postgres DSN | +| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | | +| `MEMBY_LISTEN_ADDR` | `:8080` | | +| `MEMBY_CLIENT_NAME` | `Memby` | Shown in Emby's device list | +| `MEMBY_HOME_TTL` | `60s` | Also `MEMBY_ITEM_TTL`, `MEMBY_SEARCH_TTL`, `MEMBY_SCREENSAVER_TTL` | +| `MEMBY_RECOMMEND_TTL` | `2h` | How long computed recommendation rows stay warm | +| `MEMBY_RECOMMEND_TIMEOUT` | `60s` | Bounds a background rebuild | +| `MEMBY_ADMIN_TOKEN` | *empty* | Enables `/admin`. Empty = admin disabled | +| `MEMBY_SYNC_INTERVAL` | `1h` | Incremental import cadence; `0` disables | +| `MEMBY_SYNC_TIMEOUT` | `30m` | Bounds one import | +| `MEMBY_SYNC_ON_START` | `false` | Import at boot | +| `MEMBY_SYNC_USER_ID` / `MEMBY_SYNC_API_KEY` | *empty* | Emby service account for imports | +| `MEMBY_ANALYTICS_RETENTION` | `2160h` (90d) | Raw row events are pruned past this | +| `MEMBY_SESSION_CACHE_TTL` | `5m` | How long a token lookup stays in Redis | +| `MEMBY_SESSION_IDLE_EXPIRY` | `2160h` (90d) | Unused tokens are swept every 6h | +| `MEMBY_UPSTREAM_TIMEOUT` | `20s` | | + +## Security notes + +- The `sessions` table stores **live Emby access tokens** in plaintext. Gateway tokens are + stored only as SHA-256 hashes, so a database dump does not yield working gateway + credentials — but it does yield working *Emby* ones. Treat the Postgres volume as a + secret store, and encrypting `emby_token` at rest is the obvious next hardening step. +- Image URLs carry the gateway token in a query string, so it will appear in any access + log in front of this service. That token is revocable and grants nothing outside Memby, + which is why the artwork proxy exists at all. +- Nothing here terminates TLS. Put it behind your existing reverse proxy before exposing + it beyond the LAN. + +## Not built yet + +- **Live change feed.** Imports are polled hourly rather than driven by Emby's WebSocket, + so a brand-new episode can be up to an hour late. Good enough for a household; the + WebSocket would make it instant. +- **Cache warming.** Rows go cold after `MEMBY_HOME_TTL`; the first TV to ask pays for the + refresh. A background refresher per active session would hide that. +- **Rate limiting** on `/v1/auth/login`. +- **Per-user analytics breakdown.** Events carry a user id, but the admin page only shows + totals per row. diff --git a/server/cmd/memby-server/main.go b/server/cmd/memby-server/main.go new file mode 100644 index 0000000..3b53be0 --- /dev/null +++ b/server/cmd/memby-server/main.go @@ -0,0 +1,237 @@ +// Command memby-server is the Memby gateway: one HTTP service in front of Emby that +// owns auth, caching and the shaping of TV screens, so the Android client can stay thin. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/ponzischeme89/memby/server/internal/api" + "github.com/ponzischeme89/memby/server/internal/cache" + "github.com/ponzischeme89/memby/server/internal/config" + "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/library" + "github.com/ponzischeme89/memby/server/internal/recommend" + "github.com/ponzischeme89/memby/server/internal/store" +) + +func main() { + // A distroless image has no shell or curl, so the container's healthcheck re-runs + // this binary with -healthcheck and it probes itself over the loopback interface. + healthcheck := flag.Bool("healthcheck", false, "probe the local /healthz endpoint and exit") + flag.Parse() + if *healthcheck { + if err := probeHealth(); err != nil { + os.Stderr.WriteString(err.Error() + "\n") + os.Exit(1) + } + return + } + + log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) + + if err := run(log); err != nil { + log.Error("fatal", "error", err) + os.Exit(1) + } +} + +func run(log *slog.Logger) error { + cfg, err := config.Load() + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // Postgres may still be starting when compose brings us up; retry briefly rather + // than crash-looping the container. + st, err := openStore(ctx, cfg.DatabaseURL, log) + if err != nil { + return err + } + defer st.Close() + + if err := st.Migrate(ctx); err != nil { + return err + } + + ca, err := cache.Open(cfg.RedisURL) + if err != nil { + return err + } + defer ca.Close() + if err := ca.Ping(ctx); err != nil { + return err + } + + // A run interrupted by a restart is still marked "running" in the database; clear + // those before anything reads the sync history. + if err := st.MarkStaleRunsFailed(ctx); err != nil { + log.Warn("could not clear interrupted sync runs", "error", err) + } + + embyClient := emby.New(cfg.EmbyURL, cfg.EmbyPublicURL, cfg.ClientName, cfg.UpstreamTimeout) + + recommender := recommend.NewEngine(embyClient, log) + // Candidates come from the imported library when one exists, which keeps the + // recommendation rebuild off Emby entirely. + recommender.Library = st + + syncer := library.NewSyncer(embyClient, st, emby.Credentials{ + UserID: cfg.SyncUserID, + Token: cfg.SyncAPIKey, + DeviceID: "memby-gateway-sync", + }, log) + + server := api.New(cfg, api.Deps{ + Emby: embyClient, + Store: st, + Cache: ca, + Recommender: recommender, + Syncer: syncer, + Log: log, + }) + + if err := server.LoadMaintenance(ctx); err != nil { + return err + } + go server.WatchMaintenance(ctx, 30*time.Second) + + go syncer.Schedule(ctx, cfg.SyncInterval) + if cfg.SyncOnStart { + go func() { + if _, err := syncer.Sync(ctx, "incremental", "startup"); err != nil { + log.Warn("startup sync failed", "error", err) + } + }() + } + go pruneAnalytics(ctx, st, cfg.AnalyticsRetention, log) + + httpServer := &http.Server{ + Addr: cfg.ListenAddr, + Handler: server.Routes(), + ReadHeaderTimeout: 10 * time.Second, + // No WriteTimeout: image proxying streams bodies of unpredictable size. + IdleTimeout: 60 * time.Second, + } + + go sweepIdleSessions(ctx, st, cfg.SessionIdleExpiry, log) + + errCh := make(chan error, 1) + go func() { + log.Info("listening", "addr", cfg.ListenAddr, "emby", cfg.EmbyURL) + if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + log.Info("shutting down") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return httpServer.Shutdown(shutdownCtx) + } +} + +// probeHealth is the container healthcheck: hit our own /healthz over loopback. +func probeHealth() error { + addr := os.Getenv("MEMBY_LISTEN_ADDR") + if addr == "" { + addr = ":8080" + } + // ":8080" and "0.0.0.0:8080" both mean "connect to localhost" from in here. + if idx := strings.LastIndex(addr, ":"); idx >= 0 { + addr = "127.0.0.1" + addr[idx:] + } + + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get("http://" + addr + "/healthz") + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("healthz returned %d", resp.StatusCode) + } + return nil +} + +func openStore(ctx context.Context, databaseURL string, log *slog.Logger) (*store.Store, error) { + var lastErr error + for attempt := range 10 { + st, err := store.Open(ctx, databaseURL) + if err == nil { + return st, nil + } + lastErr = err + log.Warn("waiting for postgres", "attempt", attempt+1, "error", err) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(2 * time.Second): + } + } + return nil, lastErr +} + +// pruneAnalytics keeps raw row events inside their retention window. The admin page +// aggregates at read time, so nothing survives the prune — deliberately, since this is +// tuning telemetry rather than a permanent record of what anyone watched. +func pruneAnalytics(ctx context.Context, st *store.Store, retention time.Duration, log *slog.Logger) { + if retention <= 0 { + return + } + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + removed, err := st.PruneRowEvents(ctx, retention) + if err != nil { + log.Warn("analytics prune failed", "error", err) + continue + } + if removed > 0 { + log.Info("pruned row events", "count", removed) + } + } + } +} + +// sweepIdleSessions retires gateway tokens that have not been used in a long time, so a +// TV that was factory-reset does not leave a live Emby token in the database forever. +func sweepIdleSessions(ctx context.Context, st *store.Store, idle time.Duration, log *slog.Logger) { + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + removed, err := st.DeleteIdleSessions(ctx, idle) + if err != nil { + log.Warn("session sweep failed", "error", err) + continue + } + if removed > 0 { + log.Info("retired idle sessions", "count", removed) + } + } + } +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..3934c36 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,19 @@ +module github.com/ponzischeme89/memby/server + +go 1.26 + +require ( + github.com/jackc/pgx/v5 v5.7.2 + github.com/redis/go-redis/v9 v9.7.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..7cd5d49 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,38 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go new file mode 100644 index 0000000..df0276c --- /dev/null +++ b/server/internal/api/admin.go @@ -0,0 +1,171 @@ +package api + +import ( + "context" + "crypto/subtle" + _ "embed" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/ponzischeme89/memby/server/internal/library" + "github.com/ponzischeme89/memby/server/internal/store" +) + +//go:embed admin.html +var adminPage []byte + +// adminRoutes is the operator interface: library imports, the maintenance switch, and +// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be +// left exposed by accident. +func (s *Server) adminRoutes() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /admin/{$}", s.handleAdminPage) + mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus)) + mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics)) + mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync)) + mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance)) + + return mux +} + +// adminAuth guards the admin API with a shared token, compared in constant time. +func (s *Server) adminAuth(h http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.cfg.AdminToken == "" { + http.NotFound(w, r) + return + } + presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 { + writeError(w, http.StatusUnauthorized, "invalid admin token") + return + } + h(w, r) + }) +} + +func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) { + if s.cfg.AdminToken == "" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // The page holds no secrets; the token is entered by the operator and kept in the + // browser's local storage. + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(adminPage) +} + +type adminStatus struct { + Maintenance store.Maintenance `json:"maintenance"` + Library store.LibraryStats `json:"library"` + SyncRunning bool `json:"syncRunning"` + Runs []store.SyncRun `json:"runs"` + SyncEvery string `json:"syncEvery"` +} + +func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + stats, err := s.store.LibraryStats(ctx) + if err != nil { + s.log.Error("library stats failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not read library stats") + return + } + runs, err := s.store.RecentSyncRuns(ctx, 10) + if err != nil { + s.log.Error("sync history failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not read sync history") + return + } + + writeJSON(w, http.StatusOK, adminStatus{ + Maintenance: s.maintenance.get(), + Library: stats, + SyncRunning: s.syncer.Running(), + Runs: runs, + SyncEvery: s.cfg.SyncInterval.String(), + }) +} + +type syncRequest struct { + Kind string `json:"kind"` +} + +// handleAdminSync starts an import in the background and returns immediately. A full +// import of a large library takes minutes; the page polls /admin/api/status for progress. +func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) { + var req syncRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + if req.Kind != "full" && req.Kind != "incremental" { + writeError(w, http.StatusBadRequest, `kind must be "full" or "incremental"`) + return + } + if s.syncer.Running() { + writeError(w, http.StatusConflict, "a sync is already running") + return + } + + go func() { + ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.SyncTimeout) + defer cancel() + if _, err := s.syncer.Sync(ctx, req.Kind, "manual"); err != nil { + s.log.Error("manual sync failed", "kind", req.Kind, "error", err) + } + }() + + writeJSON(w, http.StatusAccepted, map[string]string{"status": "started", "kind": req.Kind}) +} + +type maintenanceRequest struct { + Enabled bool `json:"enabled"` + Message string `json:"message"` +} + +func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request) { + var req maintenanceRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + + state := store.Maintenance{Enabled: req.Enabled, Message: strings.TrimSpace(req.Message)} + if err := s.store.SetMaintenance(r.Context(), state); err != nil { + s.log.Error("maintenance write failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not update maintenance mode") + return + } + if err := s.LoadMaintenance(r.Context()); err != nil { + s.log.Warn("maintenance reload failed", "error", err) + } + + s.log.Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message) + writeJSON(w, http.StatusOK, s.maintenance.get()) +} + +func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) { + days := queryInt(r, "days", 7, 90) + since := time.Now().UTC().AddDate(0, 0, -days) + + stats, err := s.store.RowStats(r.Context(), since) + if err != nil { + s.log.Error("row stats failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not read analytics") + return + } + writeJSON(w, http.StatusOK, map[string]any{"days": days, "rows": stats}) +} + +// syncerHandle is the slice of the syncer the API needs, so api does not depend on the +// concrete type for testing. +type syncerHandle interface { + Running() bool + Sync(ctx context.Context, kind, trigger string) (library.Result, error) +} diff --git a/server/internal/api/admin.html b/server/internal/api/admin.html new file mode 100644 index 0000000..1d168df --- /dev/null +++ b/server/internal/api/admin.html @@ -0,0 +1,288 @@ + + + + + +Memby admin + + + +

+
+

Memby admin

+ connecting… + + + +
+ + + +
+

Library

+
Loading…
+
+ + + +
+
+ +
+

Maintenance

+

+ Takes Memby offline for every TV, independently of Emby. Sign-in and all content + calls return 503 with the message below; this page keeps working. +

+
+ + + + +
+
+ +
+

Row engagement

+
+ + +
+
+ + + + + + + + + + +
RowKindDwellImpressionsFocusesOpenedOpen rateViewers
No data yet.
+
+
+ +
+

Recent imports

+
+ + + + + + + + +
StartedKindTriggerStatusSeenWrittenRemovedNotes
Nothing yet.
+
+
+
+ + + + diff --git a/server/internal/api/admin_test.go b/server/internal/api/admin_test.go new file mode 100644 index 0000000..10384dd --- /dev/null +++ b/server/internal/api/admin_test.go @@ -0,0 +1,196 @@ +package api + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ponzischeme89/memby/server/internal/config" + "github.com/ponzischeme89/memby/server/internal/store" +) + +func testServer(cfg config.Config) *Server { + return New(cfg, Deps{Log: slog.New(slog.NewTextHandler(io.Discard, nil))}) +} + +func TestMaintenanceGatePassesTrafficWhenOnline(t *testing.T) { + server := testServer(config.Config{}) + var reached bool + handler := server.maintenanceGate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + reached = true + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil)) + + if !reached || rec.Code != http.StatusOK { + t.Fatalf("request should have passed through, got %d", rec.Code) + } +} + +func TestMaintenanceGateBlocksWithTheOperatorsMessage(t *testing.T) { + server := testServer(config.Config{}) + server.maintenance.set(store.Maintenance{Enabled: true, Message: "Back at 9pm"}) + + handler := server.maintenanceGate(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("handler must not run while offline") + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil)) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rec.Code) + } + if rec.Header().Get("Retry-After") == "" { + t.Fatal("expected a Retry-After header") + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("body: %v", err) + } + // The TV keys off `maintenance` to tell "we turned it off" from "the network died". + if body["maintenance"] != true { + t.Fatalf("expected maintenance:true, got %v", body) + } + if body["message"] != "Back at 9pm" { + t.Fatalf("operator message not surfaced: %v", body["message"]) + } +} + +func TestMaintenanceGateFallsBackToADefaultMessage(t *testing.T) { + server := testServer(config.Config{}) + server.maintenance.set(store.Maintenance{Enabled: true}) + + rec := httptest.NewRecorder() + server.maintenanceGate(http.NotFoundHandler()). + ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/home", nil)) + + var body map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if body["message"] != store.DefaultMaintenanceMessage { + t.Fatalf("expected the default message, got %v", body["message"]) + } +} + +func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) { + // Health checks and the admin page sit outside the gate on purpose: they are what + // you need most while the app is deliberately down. + server := testServer(config.Config{AdminToken: "secret"}) + server.maintenance.set(store.Maintenance{Enabled: true}) + + rec := httptest.NewRecorder() + server.handleHealth(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("healthz should stay 200 during maintenance, got %d", rec.Code) + } + + rec = httptest.NewRecorder() + server.adminRoutes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("admin page should stay reachable, got %d", rec.Code) + } +} + +func TestAdminIsDisabledWithoutAToken(t *testing.T) { + server := testServer(config.Config{}) + + for _, path := range []string{"/admin/", "/admin/api/status"} { + rec := httptest.NewRecorder() + server.adminRoutes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("%s should 404 when no admin token is configured, got %d", path, rec.Code) + } + } +} + +func TestAdminAuthRejectsAWrongToken(t *testing.T) { + server := testServer(config.Config{AdminToken: "secret"}) + handler := server.adminAuth(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + cases := map[string]string{ + "missing": "", + "wrong": "Bearer nope", + "prefix": "Bearer secretish", + } + for name, header := range cases { + req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil) + if header != "" { + req.Header.Set("Authorization", header) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("%s token should be rejected, got %d", name, rec.Code) + } + } + + req := httptest.NewRequest(http.MethodGet, "/admin/api/status", nil) + req.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("the correct token should be accepted, got %d", rec.Code) + } +} + +func TestToRowEventValidatesAndClamps(t *testing.T) { + now := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) + + t.Run("rejects unknown event kinds", func(t *testing.T) { + if _, ok := toRowEvent(rowEventPayload{RowID: "r", Event: "scrolled"}, "u", now); ok { + t.Fatal("unknown event kind should be dropped") + } + }) + + t.Run("rejects events with no row", func(t *testing.T) { + if _, ok := toRowEvent(rowEventPayload{Event: "focus"}, "u", now); ok { + t.Fatal("an event with no row id should be dropped") + } + }) + + t.Run("clamps implausible dwell", func(t *testing.T) { + event, ok := toRowEvent(rowEventPayload{RowID: "r", Event: "focus", DwellMs: 99 * 60 * 60 * 1000}, "u", now) + if !ok { + t.Fatal("event should be accepted") + } + if event.DwellMs != maxDwellMs { + t.Fatalf("dwell = %d, want clamped to %d", event.DwellMs, maxDwellMs) + } + + event, _ = toRowEvent(rowEventPayload{RowID: "r", Event: "focus", DwellMs: -5}, "u", now) + if event.DwellMs != 0 { + t.Fatalf("negative dwell should floor at 0, got %d", event.DwellMs) + } + }) + + t.Run("ignores a device clock that is wildly wrong", func(t *testing.T) { + event, _ := toRowEvent( + rowEventPayload{RowID: "r", Event: "impression", OccurredAt: "1970-01-01T00:00:00Z"}, "u", now) + if !event.OccurredAt.Equal(now) { + t.Fatalf("expected the server clock to win, got %v", event.OccurredAt) + } + }) + + t.Run("accepts a plausible device timestamp", func(t *testing.T) { + earlier := now.Add(-30 * time.Second).Format(time.RFC3339) + event, _ := toRowEvent(rowEventPayload{RowID: "r", Event: "select", OccurredAt: earlier}, "u", now) + if event.OccurredAt.Equal(now) { + t.Fatal("a recent device timestamp should be kept") + } + }) + + t.Run("stamps the session's user", func(t *testing.T) { + event, _ := toRowEvent(rowEventPayload{RowID: "r", Event: "focus"}, "user-9", now) + if event.UserID != "user-9" { + t.Fatalf("user should come from the session, got %q", event.UserID) + } + }) +} diff --git a/server/internal/api/analytics.go b/server/internal/api/analytics.go new file mode 100644 index 0000000..6361709 --- /dev/null +++ b/server/internal/api/analytics.go @@ -0,0 +1,102 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/ponzischeme89/memby/server/internal/store" +) + +// maxAnalyticsBatch caps one upload. The TV batches events and flushes periodically, so +// a larger payload than this means something has gone wrong client-side. +const maxAnalyticsBatch = 200 + +// maxDwellMs discards implausible dwell times — a TV left on a row overnight says +// nothing about what anyone was looking at. +const maxDwellMs = 30 * 60 * 1000 + +type rowEventPayload struct { + RowID string `json:"rowId"` + RowKind string `json:"rowKind"` + Event string `json:"event"` + ItemID string `json:"itemId"` + DwellMs int `json:"dwellMs"` + OccurredAt string `json:"occurredAt"` +} + +type analyticsRequest struct { + Events []rowEventPayload `json:"events"` +} + +// handleRowAnalytics accepts a batch of row engagement events from a TV. +// +// Fire-and-forget by design: the client does not retry, and a rejected event is never +// worth surfacing on screen. Bad events are dropped individually rather than failing the +// batch. +func (s *Server) handleRowAnalytics(w http.ResponseWriter, r *http.Request, sess store.Session) { + var req analyticsRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + if len(req.Events) > maxAnalyticsBatch { + req.Events = req.Events[:maxAnalyticsBatch] + } + + now := time.Now().UTC() + events := make([]store.RowEvent, 0, len(req.Events)) + for _, payload := range req.Events { + event, ok := toRowEvent(payload, sess.EmbyUserID, now) + if !ok { + continue + } + events = append(events, event) + } + + if err := s.store.InsertRowEvents(r.Context(), events); err != nil { + s.log.Warn("row analytics write failed", "error", err) + // Still a 204: telemetry must never make the TV think something is broken. + } + w.WriteHeader(http.StatusNoContent) +} + +func toRowEvent(payload rowEventPayload, userID string, now time.Time) (store.RowEvent, bool) { + if payload.RowID == "" { + return store.RowEvent{}, false + } + switch payload.Event { + case store.RowEventImpression, store.RowEventFocus, store.RowEventSelect: + default: + return store.RowEvent{}, false + } + + occurredAt := now + if payload.OccurredAt != "" { + if parsed, err := time.Parse(time.RFC3339, payload.OccurredAt); err == nil { + // Trust the device's clock only within a sane window; TVs are notorious for + // waking up in 1970. + if parsed.After(now.Add(-24*time.Hour)) && parsed.Before(now.Add(time.Hour)) { + occurredAt = parsed.UTC() + } + } + } + + dwell := payload.DwellMs + if dwell < 0 { + dwell = 0 + } + if dwell > maxDwellMs { + dwell = maxDwellMs + } + + return store.RowEvent{ + OccurredAt: occurredAt, + UserID: userID, + RowID: payload.RowID, + RowKind: payload.RowKind, + Event: payload.Event, + ItemID: payload.ItemID, + DwellMs: dwell, + }, true +} diff --git a/server/internal/api/api.go b/server/internal/api/api.go new file mode 100644 index 0000000..22ae0d1 --- /dev/null +++ b/server/internal/api/api.go @@ -0,0 +1,287 @@ +// Package api exposes the gateway's HTTP surface. +// +// The API is shaped for one TV screen at a time rather than mirroring Emby: /v1/home +// returns everything the launcher renders in a single round trip, which is the whole +// point of putting a gateway in front of Emby. +package api + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/ponzischeme89/memby/server/internal/cache" + "github.com/ponzischeme89/memby/server/internal/config" + "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/recommend" + "github.com/ponzischeme89/memby/server/internal/store" +) + +type Server struct { + cfg config.Config + emby *emby.Client + store *store.Store + cache *cache.Cache + recommender *recommend.Engine + syncer syncerHandle + log *slog.Logger + + recommendationBuilds recommendationBuilds + maintenance maintenanceState +} + +// Deps are the collaborators the API needs. A struct rather than positional arguments: +// this list has grown three times already. +type Deps struct { + Emby *emby.Client + Store *store.Store + Cache *cache.Cache + Recommender *recommend.Engine + Syncer syncerHandle + Log *slog.Logger +} + +func New(cfg config.Config, deps Deps) *Server { + return &Server{ + cfg: cfg, + emby: deps.Emby, + store: deps.Store, + cache: deps.Cache, + recommender: deps.Recommender, + syncer: deps.Syncer, + log: deps.Log, + } +} + +func (s *Server) Routes() http.Handler { + // The client API lives on its own mux so maintenance mode can gate all of it at + // once, without the gate ever touching health checks or the admin page. + v1 := http.NewServeMux() + + v1.HandleFunc("POST /v1/auth/login", s.handleLogin) + v1.Handle("POST /v1/auth/logout", s.authed(s.handleLogout)) + v1.Handle("GET /v1/auth/session", s.authed(s.handleSession)) + + v1.Handle("GET /v1/home", s.authed(s.handleHome)) + v1.Handle("GET /v1/screensaver", s.authed(s.handleScreensaver)) + v1.Handle("GET /v1/search", s.authed(s.handleSearch)) + v1.Handle("GET /v1/recommendations", s.authed(s.handleRecommendations)) + + v1.Handle("GET /v1/items/{id}", s.authed(s.handleItem)) + v1.Handle("POST /v1/items/{id}/favorite", s.authed(s.handleFavorite)) + v1.Handle("POST /v1/items/{id}/played", s.authed(s.handlePlayed)) + v1.Handle("GET /v1/items/{id}/playback", s.authed(s.handlePlayback)) + v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer)) + + v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport)) + v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics)) + + v1.Handle("GET /v1/images/{itemId}/{imageType}", s.authed(s.handleImage)) + + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", s.handleHealth) + mux.HandleFunc("GET /readyz", s.handleReady) + mux.Handle("/v1/", s.maintenanceGate(v1)) + mux.Handle("/admin/", s.adminRoutes()) + + return s.withLogging(mux) +} + +// --- middleware ------------------------------------------------------------- + +type authedFunc func(http.ResponseWriter, *http.Request, store.Session) + +// authed resolves the bearer token to a session before running h. +// +// Images are also accepted with a `t=` query parameter: Coil builds plain URLs from the +// repository's helpers and cannot attach headers to them. +func (s *Server) authed(h authedFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := bearerToken(r) + if token == "" { + writeError(w, http.StatusUnauthorized, "missing token") + return + } + sess, err := s.sessionFor(r.Context(), token) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusUnauthorized, "invalid token") + return + } + s.log.Error("session lookup failed", "error", err) + writeError(w, http.StatusInternalServerError, "session lookup failed") + return + } + h(w, r, sess) + }) +} + +func (s *Server) withLogging(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + // Path only: query strings can carry image tokens. + s.log.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "ms", time.Since(start).Milliseconds(), + ) + }) +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +// --- sessions --------------------------------------------------------------- + +func bearerToken(r *http.Request) string { + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + return strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) + } + if h := r.Header.Get("X-Memby-Token"); h != "" { + return strings.TrimSpace(h) + } + return strings.TrimSpace(r.URL.Query().Get("t")) +} + +func hashToken(token string) []byte { + sum := sha256.Sum256([]byte(token)) + return sum[:] +} + +func newToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +type cachedSession struct { + EmbyUserID string `json:"u"` + EmbyToken string `json:"t"` + Username string `json:"n"` + ServerID string `json:"s"` + DeviceID string `json:"d"` +} + +// sessionFor resolves a token, using Redis to keep the hot path off Postgres. +func (s *Server) sessionFor(ctx context.Context, token string) (store.Session, error) { + hash := hashToken(token) + key := cache.SessionKey(hex.EncodeToString(hash)) + + if raw, err := s.cache.Get(ctx, key); err == nil { + var cs cachedSession + if json.Unmarshal(raw, &cs) == nil { + return store.Session{ + TokenHash: hash, + EmbyUserID: cs.EmbyUserID, + EmbyToken: cs.EmbyToken, + Username: cs.Username, + ServerID: cs.ServerID, + DeviceID: cs.DeviceID, + }, nil + } + } + + sess, err := s.store.SessionByTokenHash(ctx, hash) + if err != nil { + return store.Session{}, err + } + // Constant-time confirmation that the stored hash matches the presented token. + if subtle.ConstantTimeCompare(sess.TokenHash, hash) != 1 { + return store.Session{}, store.ErrNotFound + } + + if raw, err := json.Marshal(cachedSession{ + EmbyUserID: sess.EmbyUserID, + EmbyToken: sess.EmbyToken, + Username: sess.Username, + ServerID: sess.ServerID, + DeviceID: sess.DeviceID, + }); err == nil { + _ = s.cache.Set(ctx, key, raw, s.cfg.SessionTTL) + } + // Best-effort activity stamp; a failure here must not fail the request. + if err := s.store.Touch(ctx, hash); err != nil { + s.log.Warn("touch session failed", "error", err) + } + return sess, nil +} + +func credentials(sess store.Session) emby.Credentials { + return emby.Credentials{UserID: sess.EmbyUserID, Token: sess.EmbyToken, DeviceID: sess.DeviceID} +} + +// --- responses -------------------------------------------------------------- + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + // Headers are already out; nothing useful left to do but stop. + return + } +} + +func writeRaw(w http.ResponseWriter, status int, body []byte) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _, _ = w.Write(body) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +// writeUpstreamError mirrors Emby's status so the TV can tell "signed out" (401) from +// "server is unwell" (5xx) without parsing strings. +func (s *Server) writeUpstreamError(w http.ResponseWriter, err error, message string) { + var apiErr *emby.APIError + if errors.As(err, &apiErr) { + switch { + case apiErr.StatusCode == http.StatusUnauthorized, apiErr.StatusCode == http.StatusForbidden: + writeError(w, http.StatusUnauthorized, "emby rejected the session") + return + case apiErr.StatusCode == http.StatusNotFound: + writeError(w, http.StatusNotFound, "not found on the emby server") + return + } + } + s.log.Error(message, "error", err) + writeError(w, http.StatusBadGateway, message) +} + +func queryInt(r *http.Request, key string, fallback, max int) int { + raw := r.URL.Query().Get(key) + if raw == "" { + return fallback + } + v, err := strconv.Atoi(raw) + if err != nil || v <= 0 { + return fallback + } + if v > max { + return max + } + return v +} diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go new file mode 100644 index 0000000..ba92b58 --- /dev/null +++ b/server/internal/api/api_test.go @@ -0,0 +1,143 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ponzischeme89/memby/server/internal/emby" +) + +func TestBearerTokenSources(t *testing.T) { + t.Run("authorization header", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/v1/home", nil) + r.Header.Set("Authorization", "Bearer abc123") + if got := bearerToken(r); got != "abc123" { + t.Fatalf("got %q, want abc123", got) + } + }) + + t.Run("query parameter for image urls", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/v1/images/1/backdrop?t=abc123", nil) + if got := bearerToken(r); got != "abc123" { + t.Fatalf("got %q, want abc123", got) + } + }) + + t.Run("absent", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/v1/home", nil) + if got := bearerToken(r); got != "" { + t.Fatalf("got %q, want empty", got) + } + }) +} + +func TestHashTokenIsStable(t *testing.T) { + a, b := hashToken("token"), hashToken("token") + if string(a) != string(b) { + t.Fatal("hashing the same token produced different digests") + } + if string(a) == string(hashToken("other")) { + t.Fatal("different tokens hashed to the same digest") + } +} + +func TestNewTokenIsUnique(t *testing.T) { + seen := map[string]bool{} + for range 100 { + token, err := newToken() + if err != nil { + t.Fatalf("newToken: %v", err) + } + if seen[token] { + t.Fatal("newToken repeated a value") + } + seen[token] = true + } +} + +// Empty rows must serialise as [] so kotlinx.serialization can decode them into the +// client's non-null List fields. +func TestHomeResponseEncodesEmptyRowsAsArrays(t *testing.T) { + var resp homeResponse + ensureSlices(&resp) + + body, err := json.Marshal(resp) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, row := range []string{"continueWatching", "nextUp", "favorites", "latestMovies"} { + if _, ok := decoded[row].([]any); !ok { + t.Fatalf("row %q encoded as %T, want array", row, decoded[row]) + } + } +} + +// The four fixed rows must keep their order, ids and kinds: the client maps kinds onto +// card shapes and uses ids as Compose keys. +func TestBaseRowsShape(t *testing.T) { + rows := baseRows(homeResponse{ + ContinueWatching: []json.RawMessage{json.RawMessage(`{"Id":"1"}`)}, + Favorites: []json.RawMessage{json.RawMessage(`{"Id":"2"}`)}, + }) + + if len(rows) != 4 { + t.Fatalf("expected 4 base rows, got %d", len(rows)) + } + wantIDs := []string{"continue", "next-up", "favorites", "latest-movies"} + wantKinds := []string{"continue", "nextup", "favorites", "latest"} + for i, row := range rows { + if row.ID != wantIDs[i] { + t.Fatalf("row %d id = %q, want %q", i, row.ID, wantIDs[i]) + } + if row.Kind != wantKinds[i] { + t.Fatalf("row %d kind = %q, want %q", i, row.Kind, wantKinds[i]) + } + if row.Title == "" { + t.Fatalf("row %d has no title", i) + } + } + + // The favourites row carries the items the client used to assemble itself. + if len(rows[2].Items) != 1 { + t.Fatalf("favourites row lost its items: %+v", rows[2]) + } +} + +func TestRecommendationBuildsAreDeduplicatedPerUser(t *testing.T) { + var builds recommendationBuilds + + if !builds.begin("user-1") { + t.Fatal("first build should be allowed to start") + } + if builds.begin("user-1") { + t.Fatal("a second concurrent build for the same user must be skipped") + } + if !builds.begin("user-2") { + t.Fatal("a different user must not be blocked") + } + + builds.done("user-1") + if !builds.begin("user-1") { + t.Fatal("a build should be allowed again once the previous one finished") + } +} + +func TestSummariseReadsResumePosition(t *testing.T) { + raw := json.RawMessage(`{"Id":"42","Name":"Arrival","Type":"Movie","UserData":{"PlaybackPositionTicks":36000000000}}`) + summary, err := emby.Summarise(raw) + if err != nil { + t.Fatalf("summarise: %v", err) + } + if summary.ID != "42" || summary.Name != "Arrival" { + t.Fatalf("unexpected summary: %+v", summary) + } + if got := summary.UserData.PlaybackPositionTicks / ticksPerMillisecond; got != 3_600_000 { + t.Fatalf("resume position = %d ms, want 3600000", got) + } +} diff --git a/server/internal/api/auth.go b/server/internal/api/auth.go new file mode 100644 index 0000000..b5d8a74 --- /dev/null +++ b/server/internal/api/auth.go @@ -0,0 +1,101 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/ponzischeme89/memby/server/internal/cache" + "github.com/ponzischeme89/memby/server/internal/store" +) + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + DeviceID string `json:"deviceId"` +} + +type loginResponse struct { + Token string `json:"token"` + UserID string `json:"userId"` + Username string `json:"username"` + ServerID string `json:"serverId"` +} + +// handleLogin exchanges Emby credentials for a gateway token. +// +// The Emby access token stays here: the TV only ever holds the gateway token, so +// revoking a device is a DELETE in Postgres rather than an Emby-side cleanup. +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" { + writeError(w, http.StatusBadRequest, "username is required") + return + } + if req.DeviceID == "" { + req.DeviceID = "memby-tv" + } + + auth, err := s.emby.Authenticate(r.Context(), req.Username, req.Password, req.DeviceID) + if err != nil { + // Never echo Emby's body here: a failed sign-in is the one place a wrong + // password could be reflected back. + s.log.Warn("emby authentication failed", "username", req.Username) + writeError(w, http.StatusUnauthorized, "sign-in failed") + return + } + + token, err := newToken() + if err != nil { + s.log.Error("token generation failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not issue a token") + return + } + + sess := store.Session{ + TokenHash: hashToken(token), + EmbyUserID: auth.User.ID, + EmbyToken: auth.AccessToken, + Username: auth.User.Name, + ServerID: auth.ServerID, + DeviceID: req.DeviceID, + } + if sess.Username == "" { + sess.Username = req.Username + } + if err := s.store.CreateSession(r.Context(), sess); err != nil { + s.log.Error("session persist failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not start a session") + return + } + + writeJSON(w, http.StatusOK, loginResponse{ + Token: token, + UserID: sess.EmbyUserID, + Username: sess.Username, + ServerID: sess.ServerID, + }) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store.Session) { + if err := s.store.DeleteSession(r.Context(), sess.TokenHash); err != nil { + s.log.Error("session delete failed", "error", err) + } + _ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash))) + _ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID) + w.WriteHeader(http.StatusNoContent) +} + +// handleSession lets the TV confirm a stored token is still good before rendering. +func (s *Server) handleSession(w http.ResponseWriter, _ *http.Request, sess store.Session) { + writeJSON(w, http.StatusOK, loginResponse{ + UserID: sess.EmbyUserID, + Username: sess.Username, + ServerID: sess.ServerID, + }) +} diff --git a/server/internal/api/health.go b/server/internal/api/health.go new file mode 100644 index 0000000..2e522c2 --- /dev/null +++ b/server/internal/api/health.go @@ -0,0 +1,49 @@ +package api + +import ( + "context" + "encoding/hex" + "net/http" + "time" +) + +func hexHash(hash []byte) string { return hex.EncodeToString(hash) } + +// handleHealth is liveness: the process is up. It touches no dependency, so an +// orchestrator does not restart the container just because Emby is down. +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// handleReady is readiness: everything this service needs is reachable. +func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + + checks := map[string]string{} + status := http.StatusOK + + if err := s.store.Ping(ctx); err != nil { + checks["postgres"] = err.Error() + status = http.StatusServiceUnavailable + } else { + checks["postgres"] = "ok" + } + + if err := s.cache.Ping(ctx); err != nil { + checks["redis"] = err.Error() + status = http.StatusServiceUnavailable + } else { + checks["redis"] = "ok" + } + + // Emby being unreachable is reported but does not fail readiness: cached responses + // are still worth serving, and flapping the container would not bring Emby back. + if err := s.emby.Ping(ctx); err != nil { + checks["emby"] = err.Error() + } else { + checks["emby"] = "ok" + } + + writeJSON(w, status, checks) +} diff --git a/server/internal/api/home.go b/server/internal/api/home.go new file mode 100644 index 0000000..f31f5bb --- /dev/null +++ b/server/internal/api/home.go @@ -0,0 +1,285 @@ +package api + +import ( + "encoding/json" + "math/rand/v2" + "net/http" + "net/url" + "strconv" + "sync" + + "github.com/ponzischeme89/memby/server/internal/cache" + "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/recommend" + "github.com/ponzischeme89/memby/server/internal/store" +) + +// Field sets mirror what each TV row actually renders. Asking Emby for less is the +// single biggest lever on home-screen latency, so keep these tight. +const ( + fieldsContinue = "RunTimeTicks,SeriesName,PrimaryImageAspectRatio" + fieldsNextUp = "Overview,ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio" + fieldsRow = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio" + fieldsDetail = "Overview,Genres,MediaStreams,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio" + fieldsScreensaver = "Overview,Taglines,Genres,ProductionYear,CommunityRating,Studios,OfficialRating,RunTimeTicks" + + rowImageTypes = "Backdrop,Primary,Logo" + screensaverImageTypes = "Backdrop,Logo" +) + +type homeResponse struct { + // Rows is the home screen as the server wants it drawn: order, titles and kinds all + // decided here, so a new row (a recommendation strip, a seasonal collection) ships + // without touching the TV app. The client renders whatever arrives. + Rows []recommend.Row `json:"rows"` + + // The four fixed rows are also sent flat. They are what the client caches for an + // instant cold start, and what the direct-to-Emby path still produces. + ContinueWatching []json.RawMessage `json:"continueWatching"` + NextUp []json.RawMessage `json:"nextUp"` + Favorites []json.RawMessage `json:"favorites"` + LatestMovies []json.RawMessage `json:"latestMovies"` + + // Partial is true when at least one row failed upstream. The TV shows what arrived + // and flags a refresh error rather than blanking the screen. + Partial bool `json:"partial"` +} + +// handleHome answers the entire launcher in one round trip. +func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.Session) { + ctx := r.Context() + limit := queryInt(r, "limit", 24, 100) + key := cache.UserKey(sess.EmbyUserID, "home:"+itoa(limit)) + + if raw, err := s.cache.Get(ctx, key); err == nil { + w.Header().Set("X-Memby-Cache", "hit") + writeRaw(w, http.StatusOK, raw) + return + } + + cred := credentials(sess) + var ( + mu sync.Mutex + failures int + out homeResponse + wg sync.WaitGroup + ) + + run := func(dest *[]json.RawMessage, fetch func() (*emby.ItemsResult, error)) { + wg.Add(1) + go func() { + defer wg.Done() + result, err := fetch() + mu.Lock() + defer mu.Unlock() + if err != nil { + failures++ + s.log.Warn("home row failed", "error", err) + return + } + *dest = result.Items + }() + } + + run(&out.ContinueWatching, func() (*emby.ItemsResult, error) { + return s.emby.Items(ctx, cred, rowParams(url.Values{ + "Filters": {"IsResumable"}, + "IncludeItemTypes": {"Movie,Episode"}, + "Recursive": {"true"}, + "SortBy": {"DatePlayed"}, + "SortOrder": {"Descending"}, + "Limit": {itoa(limit)}, + }, fieldsContinue)) + }) + run(&out.NextUp, func() (*emby.ItemsResult, error) { + return s.emby.NextUp(ctx, cred, rowParams(url.Values{ + "Limit": {itoa(limit)}, + }, fieldsNextUp)) + }) + run(&out.Favorites, func() (*emby.ItemsResult, error) { + return s.emby.Items(ctx, cred, rowParams(url.Values{ + "Filters": {"IsFavorite"}, + "IncludeItemTypes": {"Movie,Series"}, + "Recursive": {"true"}, + "SortBy": {"SortName"}, + "SortOrder": {"Ascending"}, + "Limit": {itoa(limit)}, + }, fieldsRow)) + }) + run(&out.LatestMovies, func() (*emby.ItemsResult, error) { + return s.emby.Items(ctx, cred, rowParams(url.Values{ + "IncludeItemTypes": {"Movie"}, + "Recursive": {"true"}, + "SortBy": {"DateCreated"}, + "SortOrder": {"Descending"}, + "Limit": {itoa(limit)}, + }, fieldsRow)) + }) + + wg.Wait() + + if failures == 4 { + writeError(w, http.StatusBadGateway, "could not reach the emby server") + return + } + out.Partial = failures > 0 + ensureSlices(&out) + + // Recommendations are read from their own long-lived cache. A miss means this + // response ships without them and a rebuild starts in the background — the home + // screen never waits on the engine. + recommendations := s.cachedRecommendations(ctx, sess.EmbyUserID) + if recommendations == nil { + s.refreshRecommendationsInBackground(sess) + } + out.Rows = append(baseRows(out), recommendations...) + + body, err := json.Marshal(out) + if err != nil { + s.log.Error("home encode failed", "error", err) + writeError(w, http.StatusInternalServerError, "could not build the home payload") + return + } + // A partial payload is served but never cached: the next request should retry. + if !out.Partial { + if err := s.cache.Set(ctx, key, body, s.cfg.HomeTTL); err != nil { + s.log.Warn("home cache write failed", "error", err) + } + } + w.Header().Set("X-Memby-Cache", "miss") + writeRaw(w, http.StatusOK, body) +} + +// handleScreensaver serves the backdrop pool. The pool is cached and shuffled per +// request, so the Dream still looks random without re-querying Emby every few seconds. +func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess store.Session) { + ctx := r.Context() + limit := queryInt(r, "limit", 200, 400) + key := cache.UserKey(sess.EmbyUserID, "screensaver:"+itoa(limit)) + + var items []json.RawMessage + if raw, err := s.cache.Get(ctx, key); err == nil { + _ = json.Unmarshal(raw, &items) + } + + if items == nil { + result, err := s.emby.Items(ctx, credentials(sess), url.Values{ + "IncludeItemTypes": {"Movie,Series"}, + "Recursive": {"true"}, + "Filters": {"HasBackdrop"}, + "SortBy": {"Random"}, + "Limit": {itoa(limit)}, + "Fields": {fieldsScreensaver}, + "ImageTypeLimit": {"1"}, + "EnableImageTypes": {screensaverImageTypes}, + "EnableUserData": {"true"}, + }) + if err != nil { + s.writeUpstreamError(w, err, "could not load screensaver items") + return + } + items = result.Items + if raw, err := json.Marshal(items); err == nil { + _ = s.cache.Set(ctx, key, raw, s.cfg.ScreensaverTTL) + } + } + + shuffled := make([]json.RawMessage, len(items)) + copy(shuffled, items) + rand.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] }) + + writeJSON(w, http.StatusOK, map[string]any{"items": shuffled}) +} + +func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request, sess store.Session) { + ctx := r.Context() + term := r.URL.Query().Get("q") + if len(term) < 2 { + writeJSON(w, http.StatusOK, map[string]any{"items": []json.RawMessage{}}) + return + } + limit := queryInt(r, "limit", 40, 100) + key := cache.UserKey(sess.EmbyUserID, "search:"+itoa(limit)+":"+term) + + if raw, err := s.cache.Get(ctx, key); err == nil { + w.Header().Set("X-Memby-Cache", "hit") + writeRaw(w, http.StatusOK, raw) + return + } + + // The imported library answers search from Postgres, which is the difference + // between "instant" and "one round trip to Emby per keystroke". An empty result + // falls through to Emby, so search still works before the first import completes. + items, err := s.store.SearchLibrary(ctx, term, limit) + if err != nil { + s.log.Warn("library search failed; falling back to emby", "error", err) + items = nil + } + if len(items) == 0 { + result, err := s.emby.Items(ctx, credentials(sess), rowParams(url.Values{ + "SearchTerm": {term}, + "IncludeItemTypes": {"Movie,Series,Episode"}, + "Recursive": {"true"}, + "Limit": {itoa(limit)}, + }, fieldsRow)) + if err != nil { + s.writeUpstreamError(w, err, "search failed") + return + } + items = result.Items + } + + body, err := json.Marshal(map[string]any{"items": nonNil(items)}) + if err != nil { + writeError(w, http.StatusInternalServerError, "could not build search results") + return + } + if err := s.cache.Set(ctx, key, body, s.cfg.SearchTTL); err != nil { + s.log.Warn("search cache write failed", "error", err) + } + w.Header().Set("X-Memby-Cache", "miss") + writeRaw(w, http.StatusOK, body) +} + +// baseRows describes the four fixed rows. +// +// Titles live here rather than in the app so wording can change server-side. They are +// emitted even when empty: the client draws its own "Nothing in progress" message, and a +// row that vanishes as you watch things is more jarring than an empty one. +func baseRows(h homeResponse) []recommend.Row { + return []recommend.Row{ + {ID: "continue", Title: "Continue Watching", Kind: "continue", Items: h.ContinueWatching}, + {ID: "next-up", Title: "Next Up", Kind: "nextup", Items: h.NextUp}, + {ID: "favorites", Title: "Favourites", Kind: "favorites", Items: h.Favorites}, + {ID: "latest-movies", Title: "Recently Added Movies", Kind: "latest", Items: h.LatestMovies}, + } +} + +// rowParams applies the query shape every list endpoint shares. +func rowParams(params url.Values, fields string) url.Values { + params.Set("Fields", fields) + params.Set("ImageTypeLimit", "1") + params.Set("EnableImages", "true") + params.Set("EnableImageTypes", rowImageTypes) + params.Set("EnableTotalRecordCount", "false") + params.Set("EnableUserData", "true") + return params +} + +// ensureSlices keeps empty rows as [] rather than null, so kotlinx.serialization can +// decode them into non-null List fields. +func ensureSlices(h *homeResponse) { + h.ContinueWatching = nonNil(h.ContinueWatching) + h.NextUp = nonNil(h.NextUp) + h.Favorites = nonNil(h.Favorites) + h.LatestMovies = nonNil(h.LatestMovies) +} + +func nonNil(items []json.RawMessage) []json.RawMessage { + if items == nil { + return []json.RawMessage{} + } + return items +} + +func itoa(v int) string { return strconv.Itoa(v) } diff --git a/server/internal/api/images.go b/server/internal/api/images.go new file mode 100644 index 0000000..0a86556 --- /dev/null +++ b/server/internal/api/images.go @@ -0,0 +1,65 @@ +package api + +import ( + "io" + "net/http" + "net/url" + "strings" + + "github.com/ponzischeme89/memby/server/internal/store" +) + +// allowedImageTypes guards the path segment we forward to Emby. +var allowedImageTypes = map[string]string{ + "backdrop": "Backdrop", + "primary": "Primary", + "logo": "Logo", + "thumb": "Thumb", +} + +// handleImage proxies artwork. +// +// Going through the gateway means the TV's image URLs carry a gateway token instead of a +// live Emby api_key, and it gives Emby's resized output a cacheable home. Images are +// tag-addressed, so a hit can be cached hard by any layer in front of this. +func (s *Server) handleImage(w http.ResponseWriter, r *http.Request, sess store.Session) { + itemID := r.PathValue("itemId") + imageType, ok := allowedImageTypes[strings.ToLower(r.PathValue("imageType"))] + if !ok || itemID == "" { + writeError(w, http.StatusNotFound, "unknown image") + return + } + + params := url.Values{} + for _, key := range []string{"tag", "maxWidth", "maxHeight", "quality"} { + if v := r.URL.Query().Get(key); v != "" { + params.Set(key, v) + } + } + + resp, err := s.emby.ImageResponse(r.Context(), credentials(sess), itemID, imageType, params) + if err != nil { + s.writeUpstreamError(w, err, "could not load the image") + return + } + defer resp.Body.Close() + + if ct := resp.Header.Get("Content-Type"); ct != "" { + w.Header().Set("Content-Type", ct) + } + if cl := resp.Header.Get("Content-Length"); cl != "" { + w.Header().Set("Content-Length", cl) + } + // A tag identifies exact image bytes, so it can be cached indefinitely. Without one, + // stay conservative. + if params.Get("tag") != "" { + w.Header().Set("Cache-Control", "private, max-age=31536000, immutable") + } else { + w.Header().Set("Cache-Control", "private, max-age=3600") + } + + w.WriteHeader(http.StatusOK) + if _, err := io.Copy(w, resp.Body); err != nil { + s.log.Warn("image copy failed", "error", err) + } +} diff --git a/server/internal/api/items.go b/server/internal/api/items.go new file mode 100644 index 0000000..ed0dfd1 --- /dev/null +++ b/server/internal/api/items.go @@ -0,0 +1,104 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/ponzischeme89/memby/server/internal/cache" + "github.com/ponzischeme89/memby/server/internal/store" +) + +type flagRequest struct { + Value bool `json:"value"` +} + +// handleItem serves full metadata for one item. The TV asks for this only after D-pad +// focus settles, so it is worth caching for longer than a home row. +func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.Session) { + ctx := r.Context() + itemID := r.PathValue("id") + if itemID == "" { + writeError(w, http.StatusBadRequest, "item id is required") + return + } + key := cache.UserKey(sess.EmbyUserID, "item:"+itemID) + + if raw, err := s.cache.Get(ctx, key); err == nil { + w.Header().Set("X-Memby-Cache", "hit") + writeRaw(w, http.StatusOK, raw) + return + } + + item, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsDetail) + if err != nil { + s.writeUpstreamError(w, err, "could not load the item") + return + } + if err := s.cache.Set(ctx, key, item, s.cfg.ItemTTL); err != nil { + s.log.Warn("item cache write failed", "error", err) + } + w.Header().Set("X-Memby-Cache", "miss") + writeRaw(w, http.StatusOK, item) +} + +// handleTrailer answers with the item's first local trailer, or 404 when it has none. +// The screensaver's Play action uses this before asking for a playback URL. +func (s *Server) handleTrailer(w http.ResponseWriter, r *http.Request, sess store.Session) { + itemID := r.PathValue("id") + if itemID == "" { + writeError(w, http.StatusBadRequest, "item id is required") + return + } + result, err := s.emby.LocalTrailers(r.Context(), credentials(sess), itemID) + if err != nil { + s.writeUpstreamError(w, err, "could not load trailers") + return + } + if len(result.Items) == 0 { + writeError(w, http.StatusNotFound, "no trailer available") + return + } + writeRaw(w, http.StatusOK, result.Items[0]) +} + +func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request, sess store.Session) { + s.setFlag(w, r, sess, func(itemID string, value bool) (json.RawMessage, error) { + return s.emby.SetFavorite(r.Context(), credentials(sess), itemID, value) + }) +} + +func (s *Server) handlePlayed(w http.ResponseWriter, r *http.Request, sess store.Session) { + s.setFlag(w, r, sess, func(itemID string, value bool) (json.RawMessage, error) { + return s.emby.SetPlayed(r.Context(), credentials(sess), itemID, value) + }) +} + +// setFlag applies a user-data mutation and drops this user's cached views, so the next +// home request reflects it rather than serving the row it just contradicted. +func (s *Server) setFlag( + w http.ResponseWriter, + r *http.Request, + sess store.Session, + apply func(itemID string, value bool) (json.RawMessage, error), +) { + itemID := r.PathValue("id") + if itemID == "" { + writeError(w, http.StatusBadRequest, "item id is required") + return + } + var req flagRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + + userData, err := apply(itemID, req.Value) + if err != nil { + s.writeUpstreamError(w, err, "could not update the item") + return + } + if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil { + s.log.Warn("cache invalidation failed", "error", err) + } + writeRaw(w, http.StatusOK, userData) +} diff --git a/server/internal/api/maintenance.go b/server/internal/api/maintenance.go new file mode 100644 index 0000000..2c1125e --- /dev/null +++ b/server/internal/api/maintenance.go @@ -0,0 +1,87 @@ +package api + +import ( + "context" + "net/http" + "sync" + "time" + + "github.com/ponzischeme89/memby/server/internal/store" +) + +// maintenanceState caches the operator switch in memory so the hot path never queries +// Postgres, while Postgres stays the source of truth across restarts. +type maintenanceState struct { + mu sync.RWMutex + value store.Maintenance +} + +func (m *maintenanceState) get() store.Maintenance { + m.mu.RLock() + defer m.mu.RUnlock() + return m.value +} + +func (m *maintenanceState) set(value store.Maintenance) { + m.mu.Lock() + defer m.mu.Unlock() + m.value = value +} + +// LoadMaintenance primes the cached switch. Called at boot, and after every toggle. +func (s *Server) LoadMaintenance(ctx context.Context) error { + state, err := s.store.Maintenance(ctx) + if err != nil { + return err + } + s.maintenance.set(state) + if state.Enabled { + s.log.Warn("starting in maintenance mode", "message", state.Message) + } + return nil +} + +// WatchMaintenance re-reads the switch periodically, so a change made directly in the +// database (or by another instance) is picked up without a restart. +func (s *Server) WatchMaintenance(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.LoadMaintenance(ctx); err != nil { + s.log.Warn("maintenance refresh failed", "error", err) + } + } + } +} + +// maintenanceGate turns the whole client API off, independently of Emby. +// +// 503 with a machine-readable `maintenance: true` so the TV can show the operator's +// message rather than a generic network error. Admin routes and health checks are +// deliberately outside this gate — you need them most while the app is down. +func (s *Server) maintenanceGate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + state := s.maintenance.get() + if !state.Enabled { + next.ServeHTTP(w, r) + return + } + + message := state.Message + if message == "" { + message = store.DefaultMaintenanceMessage + } + // Retry-After keeps well-behaved clients from hammering a service that has + // already said it is unavailable. + w.Header().Set("Retry-After", "300") + writeJSON(w, http.StatusServiceUnavailable, map[string]any{ + "error": message, + "maintenance": true, + "message": message, + }) + }) +} diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go new file mode 100644 index 0000000..e98d3b1 --- /dev/null +++ b/server/internal/api/playback.go @@ -0,0 +1,159 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "strings" + + "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/store" +) + +const ticksPerMillisecond = 10_000 + +type playbackResponse struct { + ItemID string `json:"itemId"` + Title string `json:"title"` + URL string `json:"url"` + ResumePositionMs int64 `json:"resumePositionMs"` +} + +type playbackReport struct { + ItemID string `json:"itemId"` + PositionMs int64 `json:"positionMs"` + IsPaused bool `json:"isPaused"` +} + +// handlePlayback resolves what to actually play. +// +// This is logic the TV used to carry: a series resolves to its next-up episode (falling +// back to the first), and the returned URL points straight at Emby so the video stream +// never traverses the gateway. +func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess store.Session) { + ctx := r.Context() + itemID := r.PathValue("id") + if itemID == "" { + writeError(w, http.StatusBadRequest, "item id is required") + return + } + cred := credentials(sess) + + raw, err := s.emby.Item(ctx, cred, itemID, "RunTimeTicks,SeriesName") + if err != nil { + s.writeUpstreamError(w, err, "could not load the item") + return + } + item, err := emby.Summarise(raw) + if err != nil { + writeError(w, http.StatusBadGateway, "unreadable item from emby") + return + } + + target := item + title := item.Name + + if strings.EqualFold(item.Type, "Series") { + episode, err := s.firstPlayableEpisode(ctx, cred, item.ID) + if err != nil { + s.writeUpstreamError(w, err, "could not find an episode to play") + return + } + if episode == nil { + writeError(w, http.StatusNotFound, "no episodes found for this series") + return + } + target = *episode + if episode.Name != "" { + title = item.Name + " – " + episode.Name + } + } + + writeJSON(w, http.StatusOK, playbackResponse{ + ItemID: target.ID, + Title: title, + URL: s.emby.StreamURL(cred, target.ID), + ResumePositionMs: max64(target.UserData.PlaybackPositionTicks/ticksPerMillisecond, 0), + }) +} + +// firstPlayableEpisode prefers the server's next-up choice and falls back to episode one. +func (s *Server) firstPlayableEpisode(ctx context.Context, cred emby.Credentials, seriesID string) (*emby.Summary, error) { + nextUp, err := s.emby.NextUp(ctx, cred, url.Values{ + "SeriesId": {seriesID}, + "Limit": {"1"}, + "Fields": {"RunTimeTicks"}, + "EnableUserData": {"true"}, + }) + if err == nil && len(nextUp.Items) > 0 { + if summary, err := emby.Summarise(nextUp.Items[0]); err == nil { + return &summary, nil + } + } + + episodes, err := s.emby.Episodes(ctx, cred, seriesID, url.Values{ + "Limit": {"1"}, + "Fields": {"RunTimeTicks"}, + "EnableUserData": {"true"}, + }) + if err != nil { + return nil, err + } + if len(episodes.Items) == 0 { + return nil, nil + } + summary, err := emby.Summarise(episodes.Items[0]) + if err != nil { + return nil, err + } + return &summary, nil +} + +// handlePlaybackReport forwards progress to Emby. Stopping invalidates the user's cache +// so Continue Watching reflects the new position on the next home load. +func (s *Server) handlePlaybackReport(w http.ResponseWriter, r *http.Request, sess store.Session) { + phase := r.PathValue("phase") + switch phase { + case "started", "progress", "stopped": + default: + writeError(w, http.StatusNotFound, "unknown playback phase") + return + } + + var report playbackReport + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&report); err != nil { + writeError(w, http.StatusBadRequest, "malformed request body") + return + } + if report.ItemID == "" { + writeError(w, http.StatusBadRequest, "itemId is required") + return + } + + err := s.emby.ReportPlayback(r.Context(), credentials(sess), phase, report.ItemID, + max64(report.PositionMs, 0)*ticksPerMillisecond, report.IsPaused) + if err != nil { + // A dropped progress report is not worth failing playback over; log and accept. + s.log.Warn("playback report failed", "phase", phase, "error", err) + } + + if phase == "stopped" { + if err := s.cache.InvalidateUser(r.Context(), sess.EmbyUserID); err != nil { + s.log.Warn("cache invalidation failed", "error", err) + } + // Finishing something is the one event that genuinely changes viewing history, + // so it is also the only thing that retires the recommendation rows. + if err := s.cache.InvalidateRecommendations(r.Context(), sess.EmbyUserID); err != nil { + s.log.Warn("recommendation invalidation failed", "error", err) + } + } + w.WriteHeader(http.StatusNoContent) +} + +func max64(v, floor int64) int64 { + if v < floor { + return floor + } + return v +} diff --git a/server/internal/api/recommend.go b/server/internal/api/recommend.go new file mode 100644 index 0000000..897900a --- /dev/null +++ b/server/internal/api/recommend.go @@ -0,0 +1,132 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "time" + + "github.com/ponzischeme89/memby/server/internal/cache" + "github.com/ponzischeme89/memby/server/internal/recommend" + "github.com/ponzischeme89/memby/server/internal/store" +) + +// recommendationBuilds tracks in-flight rebuilds per user. +// +// Without this, four TVs waking up together would each kick off the same handful of Emby +// queries. The first one through does the work; the rest skip it and pick the rows up on +// their next home load. +type recommendationBuilds struct { + mu sync.Mutex + running map[string]bool +} + +func (b *recommendationBuilds) begin(userID string) bool { + b.mu.Lock() + defer b.mu.Unlock() + if b.running == nil { + b.running = map[string]bool{} + } + if b.running[userID] { + return false + } + b.running[userID] = true + return true +} + +func (b *recommendationBuilds) done(userID string) { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.running, userID) +} + +// cachedRecommendations returns the stored rows, or nil on a miss. +func (s *Server) cachedRecommendations(ctx context.Context, userID string) []recommend.Row { + raw, err := s.cache.Get(ctx, cache.RecommendationsKey(userID)) + if err != nil { + return nil + } + var rows []recommend.Row + if err := json.Unmarshal(raw, &rows); err != nil { + return nil + } + return rows +} + +// buildRecommendations computes and caches rows for one user. +func (s *Server) buildRecommendations(ctx context.Context, sess store.Session) ([]recommend.Row, error) { + rows, err := s.recommender.BuildRows(ctx, credentials(sess)) + if err != nil { + return nil, err + } + // An empty result is cached too: a user with no history should not trigger a full + // rebuild on every single home load. + if raw, err := json.Marshal(rows); err == nil { + if err := s.cache.Set(ctx, cache.RecommendationsKey(sess.EmbyUserID), raw, s.cfg.RecommendTTL); err != nil { + s.log.Warn("recommendation cache write failed", "error", err) + } + } + return rows, nil +} + +// refreshRecommendationsInBackground rebuilds without holding up the caller. +// +// The home screen must stay fast, so a cold cache means "no recommendation rows this +// time" rather than "wait several seconds for Emby". The rows appear on the next load. +func (s *Server) refreshRecommendationsInBackground(sess store.Session) { + if !s.recommendationBuilds.begin(sess.EmbyUserID) { + return + } + go func() { + defer s.recommendationBuilds.done(sess.EmbyUserID) + + // Detached from the request: the TV's connection is long gone by the time this + // finishes, but the work is still worth completing. + ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.RecommendTimeout) + defer cancel() + + started := time.Now() + rows, err := s.buildRecommendations(ctx, sess) + if err != nil { + s.log.Warn("recommendation build failed", "user", sess.EmbyUserID, "error", err) + return + } + s.log.Info("recommendations rebuilt", + "user", sess.EmbyUserID, "rows", len(rows), "ms", time.Since(started).Milliseconds()) + }() +} + +// handleRecommendations serves the rows on their own, building synchronously when the +// cache is cold. `?refresh=1` forces a rebuild — useful for testing the engine without +// waiting out the TTL. +func (s *Server) handleRecommendations(w http.ResponseWriter, r *http.Request, sess store.Session) { + ctx := r.Context() + forceRefresh := r.URL.Query().Get("refresh") == "1" + + if !forceRefresh { + if rows := s.cachedRecommendations(ctx, sess.EmbyUserID); rows != nil { + w.Header().Set("X-Memby-Cache", "hit") + writeJSON(w, http.StatusOK, map[string]any{"rows": rows}) + return + } + } + + buildCtx, cancel := context.WithTimeout(ctx, s.cfg.RecommendTimeout) + defer cancel() + + rows, err := s.buildRecommendations(buildCtx, sess) + if err != nil { + s.writeUpstreamError(w, err, "could not build recommendations") + return + } + w.Header().Set("X-Memby-Cache", "miss") + writeJSON(w, http.StatusOK, map[string]any{"rows": nonNilRows(rows)}) +} + +func nonNilRows(rows []recommend.Row) []recommend.Row { + if rows == nil { + return []recommend.Row{} + } + return rows +} diff --git a/server/internal/cache/cache.go b/server/internal/cache/cache.go new file mode 100644 index 0000000..7d2322b --- /dev/null +++ b/server/internal/cache/cache.go @@ -0,0 +1,97 @@ +// Package cache wraps Redis with the small surface the gateway needs. +// +// Every cached value is scoped to an Emby user id, because "what's on the home screen" +// is per-user. Mutations (favourite, watched, playback stopped) drop that user's keys +// so the next request re-reads Emby rather than serving a stale row. +package cache + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +// ErrMiss means the key was absent — an ordinary outcome, not a failure. +var ErrMiss = errors.New("cache: miss") + +type Cache struct { + rdb *redis.Client +} + +func Open(redisURL string) (*Cache, error) { + opts, err := redis.ParseURL(redisURL) + if err != nil { + return nil, fmt.Errorf("cache: parse url: %w", err) + } + return &Cache{rdb: redis.NewClient(opts)}, nil +} + +func (c *Cache) Close() error { return c.rdb.Close() } + +func (c *Cache) Ping(ctx context.Context) error { return c.rdb.Ping(ctx).Err() } + +func (c *Cache) Get(ctx context.Context, key string) ([]byte, error) { + b, err := c.rdb.Get(ctx, key).Bytes() + if errors.Is(err, redis.Nil) { + return nil, ErrMiss + } + if err != nil { + return nil, err + } + return b, nil +} + +func (c *Cache) Set(ctx context.Context, key string, value []byte, ttl time.Duration) error { + return c.rdb.Set(ctx, key, value, ttl).Err() +} + +func (c *Cache) Delete(ctx context.Context, keys ...string) error { + if len(keys) == 0 { + return nil + } + return c.rdb.Del(ctx, keys...).Err() +} + +// InvalidateUser drops every cached view belonging to one Emby user. +// +// SCAN rather than KEYS so a large keyspace never blocks Redis; the key count here is +// small, but the habit costs nothing. +func (c *Cache) InvalidateUser(ctx context.Context, userID string) error { + pattern := fmt.Sprintf("u:%s:*", userID) + var cursor uint64 + for { + keys, next, err := c.rdb.Scan(ctx, cursor, pattern, 200).Result() + if err != nil { + return err + } + if len(keys) > 0 { + if err := c.rdb.Del(ctx, keys...).Err(); err != nil { + return err + } + } + if next == 0 { + return nil + } + cursor = next + } +} + +// UserKey builds the namespaced key used by everything user-scoped. +func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID, view) } + +// RecommendationsKey sits in its own `r:` namespace on purpose. +// +// Recommendations cost several Emby queries to build, so they must survive the cache +// wipe that every favourite toggle triggers. Only a genuine change in viewing history +// — a finished playback — retires them, via [Cache.InvalidateRecommendations]. +func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows", userID) } + +func (c *Cache) InvalidateRecommendations(ctx context.Context, userID string) error { + return c.Delete(ctx, RecommendationsKey(userID)) +} + +// SessionKey caches a token→session lookup, keyed by token hash (never the token). +func SessionKey(tokenHashHex string) string { return "sess:" + tokenHashHex } diff --git a/server/internal/config/config.go b/server/internal/config/config.go new file mode 100644 index 0000000..88903c4 --- /dev/null +++ b/server/internal/config/config.go @@ -0,0 +1,136 @@ +// Package config loads the gateway's settings from the environment. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + ListenAddr string + + // EmbyURL is how the gateway itself reaches Emby (may be a private/docker address). + EmbyURL string + // EmbyPublicURL is the address handed to TV clients for direct video playback. + // Defaults to EmbyURL; set it when the gateway talks to Emby over a network the + // TVs cannot reach. + EmbyPublicURL string + + DatabaseURL string + RedisURL string + + // ClientName is reported to Emby in the X-Emby-Authorization header, so sessions + // show up as this in Emby's dashboard. + ClientName string + + HomeTTL time.Duration + ItemTTL time.Duration + SearchTTL time.Duration + ScreensaverTTL time.Duration + SessionTTL time.Duration + // SessionIdleExpiry retires gateway tokens that go unused for this long. + SessionIdleExpiry time.Duration + + // RecommendTTL is how long computed recommendation rows stay warm. Long, because + // taste moves slowly and each rebuild costs several Emby queries. + RecommendTTL time.Duration + // RecommendTimeout bounds a background rebuild, which fans out further than a + // normal request and so needs more headroom than UpstreamTimeout. + RecommendTimeout time.Duration + + UpstreamTimeout time.Duration + + // AdminToken guards the operator interface. Empty disables /admin entirely, so an + // unconfigured deployment cannot leave it exposed. + AdminToken string + + // SyncInterval is how often the library import runs. Zero disables the schedule. + SyncInterval time.Duration + // SyncTimeout bounds one import; a full pass over a large library is slow. + SyncTimeout time.Duration + // SyncOnStart triggers an incremental import at boot. + SyncOnStart bool + // SyncUserID / SyncAPIKey are an optional Emby service account for imports. Without + // them the newest TV session is borrowed instead. + SyncUserID string + SyncAPIKey string + + // AnalyticsRetention is how long raw row events are kept before being pruned. + AnalyticsRetention time.Duration +} + +func Load() (Config, error) { + c := Config{ + ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"), + EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"), + EmbyPublicURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_PUBLIC_URL"), "/"), + DatabaseURL: os.Getenv("MEMBY_DATABASE_URL"), + RedisURL: env("MEMBY_REDIS_URL", "redis://localhost:6379/0"), + ClientName: env("MEMBY_CLIENT_NAME", "Memby"), + HomeTTL: duration("MEMBY_HOME_TTL", 60*time.Second), + ItemTTL: duration("MEMBY_ITEM_TTL", 10*time.Minute), + SearchTTL: duration("MEMBY_SEARCH_TTL", 5*time.Minute), + ScreensaverTTL: duration("MEMBY_SCREENSAVER_TTL", 10*time.Minute), + SessionTTL: duration("MEMBY_SESSION_CACHE_TTL", 5*time.Minute), + SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour), + RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 2*time.Hour), + RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second), + + AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")), + SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour), + SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute), + SyncOnStart: boolean("MEMBY_SYNC_ON_START", false), + SyncUserID: strings.TrimSpace(os.Getenv("MEMBY_SYNC_USER_ID")), + SyncAPIKey: strings.TrimSpace(os.Getenv("MEMBY_SYNC_API_KEY")), + AnalyticsRetention: duration("MEMBY_ANALYTICS_RETENTION", 90*24*time.Hour), + UpstreamTimeout: duration("MEMBY_UPSTREAM_TIMEOUT", 20*time.Second), + } + + if c.EmbyURL == "" { + return c, fmt.Errorf("MEMBY_EMBY_URL is required") + } + if c.DatabaseURL == "" { + return c, fmt.Errorf("MEMBY_DATABASE_URL is required") + } + if c.EmbyPublicURL == "" { + c.EmbyPublicURL = c.EmbyURL + } + return c, nil +} + +func env(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +func boolean(key string, fallback bool) bool { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + value, err := strconv.ParseBool(raw) + if err != nil { + return fallback + } + return value +} + +func duration(key string, fallback time.Duration) time.Duration { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return fallback + } + if d, err := time.ParseDuration(raw); err == nil { + return d + } + // Bare numbers are read as seconds, which is friendlier in a compose file. + if secs, err := strconv.Atoi(raw); err == nil { + return time.Duration(secs) * time.Second + } + return fallback +} diff --git a/server/internal/emby/client.go b/server/internal/emby/client.go new file mode 100644 index 0000000..475c5fd --- /dev/null +++ b/server/internal/emby/client.go @@ -0,0 +1,325 @@ +// Package emby is a small client for the Emby REST API. +// +// Item payloads are deliberately carried as json.RawMessage and forwarded to the TV +// untouched: the Android client already models Emby's item shape, so passing it through +// verbatim means there is no second schema to keep in sync. Only the handful of fields +// the gateway itself reasons about (id, type, resume position) are ever unmarshalled. +package emby + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type Client struct { + baseURL string + publicURL string + clientName string + http *http.Client +} + +// Credentials identify one signed-in Emby user. +type Credentials struct { + UserID string + Token string + DeviceID string +} + +type ItemsResult struct { + Items []json.RawMessage `json:"Items"` + TotalRecordCount int `json:"TotalRecordCount"` +} + +type AuthResult struct { + User struct { + ID string `json:"Id"` + Name string `json:"Name"` + } `json:"User"` + AccessToken string `json:"AccessToken"` + ServerID string `json:"ServerId"` +} + +// Summary is the minimal view of an item the gateway needs for its own logic. +type Summary struct { + ID string `json:"Id"` + Name string `json:"Name"` + Type string `json:"Type"` + UserData struct { + PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` + } `json:"UserData"` +} + +// APIError carries an upstream Emby status code so handlers can mirror it. +type APIError struct { + StatusCode int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("emby: status %d: %s", e.StatusCode, e.Body) +} + +func New(baseURL, publicURL, clientName string, timeout time.Duration) *Client { + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + publicURL: strings.TrimRight(publicURL, "/"), + clientName: clientName, + http: &http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 20, + IdleConnTimeout: 90 * time.Second, + }, + }, + } +} + +func (c *Client) Authenticate(ctx context.Context, username, password, deviceID string) (*AuthResult, error) { + body, err := json.Marshal(map[string]string{"Username": username, "Pw": password}) + if err != nil { + return nil, err + } + req, err := c.newRequest(ctx, http.MethodPost, "/Users/AuthenticateByName", nil, + Credentials{DeviceID: deviceID}, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + var out AuthResult + if err := c.do(req, &out); err != nil { + return nil, err + } + if out.AccessToken == "" || out.User.ID == "" { + return nil, fmt.Errorf("emby: authentication returned no access token") + } + return &out, nil +} + +func (c *Client) Items(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) { + return c.items(ctx, cred, "/Users/"+url.PathEscape(cred.UserID)+"/Items", params) +} + +func (c *Client) NextUp(ctx context.Context, cred Credentials, params url.Values) (*ItemsResult, error) { + params.Set("UserId", cred.UserID) + return c.items(ctx, cred, "/Shows/NextUp", params) +} + +// Similar asks Emby which items resemble one the user already watched. +func (c *Client) Similar(ctx context.Context, cred Credentials, itemID string, params url.Values) (*ItemsResult, error) { + params.Set("UserId", cred.UserID) + return c.items(ctx, cred, "/Items/"+url.PathEscape(itemID)+"/Similar", params) +} + +func (c *Client) Episodes(ctx context.Context, cred Credentials, seriesID string, params url.Values) (*ItemsResult, error) { + params.Set("UserId", cred.UserID) + return c.items(ctx, cred, "/Shows/"+url.PathEscape(seriesID)+"/Episodes", params) +} + +// LocalTrailers returns the trailers Emby holds locally for an item. +func (c *Client) LocalTrailers(ctx context.Context, cred Credentials, itemID string) (*ItemsResult, error) { + path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + "/LocalTrailers" + req, err := c.newRequest(ctx, http.MethodGet, path, nil, cred, nil) + if err != nil { + return nil, err + } + // This endpoint answers with a bare array rather than an Items envelope. + var items []json.RawMessage + if err := c.do(req, &items); err != nil { + return nil, err + } + return &ItemsResult{Items: items, TotalRecordCount: len(items)}, nil +} + +func (c *Client) Item(ctx context.Context, cred Credentials, itemID, fields string) (json.RawMessage, error) { + params := url.Values{} + if fields != "" { + params.Set("Fields", fields) + } + path := "/Users/" + url.PathEscape(cred.UserID) + "/Items/" + url.PathEscape(itemID) + req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil) + if err != nil { + return nil, err + } + var raw json.RawMessage + if err := c.do(req, &raw); err != nil { + return nil, err + } + return raw, nil +} + +// SetFavorite/SetPlayed return Emby's resulting UserData verbatim. +func (c *Client) SetFavorite(ctx context.Context, cred Credentials, itemID string, favorite bool) (json.RawMessage, error) { + method := http.MethodDelete + if favorite { + method = http.MethodPost + } + path := "/Users/" + url.PathEscape(cred.UserID) + "/FavoriteItems/" + url.PathEscape(itemID) + return c.userDataCall(ctx, method, path, cred) +} + +func (c *Client) SetPlayed(ctx context.Context, cred Credentials, itemID string, played bool) (json.RawMessage, error) { + method := http.MethodDelete + if played { + method = http.MethodPost + } + path := "/Users/" + url.PathEscape(cred.UserID) + "/PlayedItems/" + url.PathEscape(itemID) + return c.userDataCall(ctx, method, path, cred) +} + +// ReportPlayback forwards a progress report. phase is "started", "progress" or "stopped". +func (c *Client) ReportPlayback(ctx context.Context, cred Credentials, phase string, itemID string, positionTicks int64, isPaused bool) error { + var path string + switch phase { + case "started": + path = "/Sessions/Playing" + case "progress": + path = "/Sessions/Playing/Progress" + case "stopped": + path = "/Sessions/Playing/Stopped" + default: + return fmt.Errorf("emby: unknown playback phase %q", phase) + } + + body, err := json.Marshal(map[string]any{ + "ItemId": itemID, + "PositionTicks": positionTicks, + "IsPaused": isPaused, + "IsMuted": false, + "CanSeek": true, + "PlayMethod": "DirectPlay", + }) + if err != nil { + return err + } + req, err := c.newRequest(ctx, http.MethodPost, path, nil, cred, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + return c.do(req, nil) +} + +// ImageResponse streams an image straight from Emby so the caller can copy it to the TV. +// The caller owns closing the body. +func (c *Client) ImageResponse(ctx context.Context, cred Credentials, itemID, imageType string, params url.Values) (*http.Response, error) { + path := "/Items/" + url.PathEscape(itemID) + "/Images/" + url.PathEscape(imageType) + req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil) + if err != nil { + return nil, err + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + resp.Body.Close() + return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)} + } + return resp, nil +} + +// StreamURL is the direct-play URL handed to the TV. It points at the *public* Emby +// address: video never flows through the gateway, only metadata does. +func (c *Client) StreamURL(cred Credentials, itemID string) string { + params := url.Values{} + params.Set("static", "true") + params.Set("api_key", cred.Token) + params.Set("DeviceId", cred.DeviceID) + return fmt.Sprintf("%s/Videos/%s/stream?%s", c.publicURL, url.PathEscape(itemID), params.Encode()) +} + +// Ping checks that Emby is reachable, for readiness probes. +func (c *Client) Ping(ctx context.Context) error { + req, err := c.newRequest(ctx, http.MethodGet, "/System/Info/Public", nil, Credentials{}, nil) + if err != nil { + return err + } + return c.do(req, nil) +} + +func (c *Client) items(ctx context.Context, cred Credentials, path string, params url.Values) (*ItemsResult, error) { + req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil) + if err != nil { + return nil, err + } + var out ItemsResult + if err := c.do(req, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) userDataCall(ctx context.Context, method, path string, cred Credentials) (json.RawMessage, error) { + req, err := c.newRequest(ctx, method, path, nil, cred, nil) + if err != nil { + return nil, err + } + var raw json.RawMessage + if err := c.do(req, &raw); err != nil { + return nil, err + } + return raw, nil +} + +func (c *Client) newRequest(ctx context.Context, method, path string, params url.Values, cred Credentials, body io.Reader) (*http.Request, error) { + full := c.baseURL + path + if len(params) > 0 { + full += "?" + params.Encode() + } + req, err := http.NewRequestWithContext(ctx, method, full, body) + if err != nil { + return nil, err + } + + deviceID := cred.DeviceID + if deviceID == "" { + deviceID = "memby-gateway" + } + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Emby-Authorization", fmt.Sprintf( + `MediaBrowser Client="%s", Device="Memby Gateway", DeviceId="%s", Version="1.0"`, + c.clientName, deviceID, + )) + if cred.Token != "" { + req.Header.Set("X-Emby-Token", cred.Token) + } + return req, nil +} + +// do executes a request and decodes into out (which may be nil to discard the body). +func (c *Client) do(req *http.Request, out any) error { + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + // Emby error bodies can echo request details; cap what we keep and never log it + // alongside a token. + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return &APIError{StatusCode: resp.StatusCode, Body: string(body)} + } + if out == nil { + _, _ = io.Copy(io.Discard, resp.Body) + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// Summarise unmarshals the fields the gateway reasons about from a raw item. +func Summarise(raw json.RawMessage) (Summary, error) { + var s Summary + err := json.Unmarshal(raw, &s) + return s, err +} diff --git a/server/internal/library/syncer.go b/server/internal/library/syncer.go new file mode 100644 index 0000000..e7933a3 --- /dev/null +++ b/server/internal/library/syncer.go @@ -0,0 +1,342 @@ +// Package library imports Emby's catalogue into Postgres so the gateway can answer from +// its own copy instead of asking Emby on every request. +// +// Two shapes of import: +// +// - **full** — page through everything, then delete whatever the pass did not touch. +// Run once to seed, and again whenever the library has been reorganised. +// - **incremental** — ask Emby only for items changed since the last successful run. +// Cheap enough to run hourly, which is what new episodes need; films appearing +// weekly are picked up by the same pass. +// +// Only shared metadata is imported (EnableUserData=false). Watched state, favourites and +// resume positions are per-user and stay live. +package library + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/ponzischeme89/memby/server/internal/emby" + "github.com/ponzischeme89/memby/server/internal/store" +) + +const ( + // pageSize balances round trips against Emby's response size. 500 items of metadata + // is roughly a megabyte of JSON. + pageSize = 500 + + // syncFields is everything the gateway serves or filters on. Images are requested as + // tags only — the artwork itself is proxied on demand. + syncFields = "Genres,Studios,Overview,Taglines,ProductionYear,CommunityRating,OfficialRating," + + "RunTimeTicks,SeriesName,PrimaryImageAspectRatio,DateCreated,MediaStreams" + + syncImageTypes = "Backdrop,Primary,Logo,Thumb" + syncItemTypes = "Movie,Series,Episode" +) + +// ErrNoCredentials means nothing has ever signed in and no service account is set, so +// there is no way to talk to Emby on the library's behalf. +var ErrNoCredentials = errors.New("library: no emby credentials available for sync") + +type Syncer struct { + emby *emby.Client + store *store.Store + log *slog.Logger + + // serviceCred is the optional configured account. When empty, the newest TV session + // is borrowed instead. + serviceCred emby.Credentials + + mu sync.Mutex + running bool +} + +func NewSyncer(embyClient *emby.Client, st *store.Store, serviceCred emby.Credentials, log *slog.Logger) *Syncer { + return &Syncer{emby: embyClient, store: st, serviceCred: serviceCred, log: log} +} + +// Result summarises one import. +type Result struct { + Kind string `json:"kind"` + Seen int `json:"seen"` + Upserted int `json:"upserted"` + Removed int `json:"removed"` + Duration time.Duration `json:"-"` + DurationMs int64 `json:"durationMs"` +} + +// Running reports whether an import is in flight, so the admin page can disable its +// buttons and the scheduler can skip a tick. +func (s *Syncer) Running() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.running +} + +// Sync runs an import. kind is "full" or "incremental"; an incremental run with no prior +// successful sync silently upgrades itself to a full one, because there is no watermark +// to work from. +func (s *Syncer) Sync(ctx context.Context, kind, trigger string) (Result, error) { + s.mu.Lock() + if s.running { + s.mu.Unlock() + return Result{}, errors.New("library: a sync is already running") + } + s.running = true + s.mu.Unlock() + defer func() { + s.mu.Lock() + s.running = false + s.mu.Unlock() + }() + + cred, err := s.credentials(ctx) + if err != nil { + return Result{}, err + } + + var since *time.Time + if kind == "incremental" { + since, err = s.store.LastSuccessfulSyncAt(ctx) + if err != nil { + return Result{}, err + } + if since == nil { + s.log.Info("no previous sync; upgrading to a full import") + kind = "full" + } + } + + startedAt := time.Now().UTC() + runID, err := s.store.StartSyncRun(ctx, kind, trigger) + if err != nil { + return Result{}, err + } + + result, syncErr := s.run(ctx, cred, kind, since, startedAt) + result.Kind = kind + result.Duration = time.Since(startedAt) + result.DurationMs = result.Duration.Milliseconds() + + record := store.SyncRun{ + Status: store.SyncStatusSuccess, + ItemsSeen: result.Seen, + ItemsUpserted: result.Upserted, + ItemsRemoved: result.Removed, + } + if syncErr != nil { + record.Status = store.SyncStatusFailed + record.Error = syncErr.Error() + } + // Always record the outcome, even when the caller's context died mid-import. + if err := s.store.FinishSyncRun(context.WithoutCancel(ctx), runID, record); err != nil { + s.log.Error("could not record sync run", "error", err) + } + + if syncErr != nil { + return result, syncErr + } + s.log.Info("library sync finished", + "kind", kind, "trigger", trigger, "seen", result.Seen, + "upserted", result.Upserted, "removed", result.Removed, "ms", result.DurationMs) + return result, nil +} + +func (s *Syncer) run( + ctx context.Context, + cred emby.Credentials, + kind string, + since *time.Time, + syncedAt time.Time, +) (Result, error) { + var result Result + + for startIndex := 0; ; startIndex += pageSize { + params := url.Values{ + "IncludeItemTypes": {syncItemTypes}, + "Recursive": {"true"}, + "SortBy": {"DateCreated"}, + "SortOrder": {"Ascending"}, + "StartIndex": {strconv.Itoa(startIndex)}, + "Limit": {strconv.Itoa(pageSize)}, + "Fields": {syncFields}, + "ImageTypeLimit": {"1"}, + "EnableImages": {"true"}, + "EnableImageTypes": {syncImageTypes}, + "EnableTotalRecordCount": {"false"}, + // The imported copy is shared by every user, so it must not carry one + // user's watched/favourite state. + "EnableUserData": {"false"}, + } + if since != nil { + // Emby returns items created or edited after this instant. Overlap by a + // minute so an item saved during the previous run is not missed. + params.Set("MinDateLastSaved", since.Add(-time.Minute).UTC().Format(time.RFC3339)) + } + + page, err := s.emby.Items(ctx, cred, params) + if err != nil { + return result, fmt.Errorf("library: fetch page at %d: %w", startIndex, err) + } + if len(page.Items) == 0 { + break + } + + items := make([]store.LibraryItem, 0, len(page.Items)) + for _, raw := range page.Items { + if item, ok := toLibraryItem(raw); ok { + items = append(items, item) + } + } + written, err := s.store.UpsertLibraryItems(ctx, items, syncedAt) + if err != nil { + return result, err + } + + result.Seen += len(page.Items) + result.Upserted += int(written) + + if len(page.Items) < pageSize { + break + } + } + + // Only a full pass has seen everything, so only a full pass may delete. + if kind == "full" { + removed, err := s.store.DeleteLibraryItemsBefore(ctx, syncedAt) + if err != nil { + return result, err + } + result.Removed = int(removed) + } + return result, nil +} + +// credentials prefers the configured service account and otherwise borrows the most +// recent TV session. +func (s *Syncer) credentials(ctx context.Context) (emby.Credentials, error) { + if s.serviceCred.Token != "" && s.serviceCred.UserID != "" { + return s.serviceCred, nil + } + sess, err := s.store.NewestSession(ctx) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return emby.Credentials{}, ErrNoCredentials + } + return emby.Credentials{}, err + } + return emby.Credentials{ + UserID: sess.EmbyUserID, + Token: sess.EmbyToken, + DeviceID: "memby-gateway-sync", + }, nil +} + +// Schedule runs an incremental import on an interval until ctx is cancelled. +// +// New episodes tend to land through the day and films weekly; an hourly incremental pass +// covers both without ever asking Emby for the whole catalogue again. +func (s *Syncer) Schedule(ctx context.Context, interval time.Duration) { + if interval <= 0 { + s.log.Info("library auto-sync disabled") + return + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + s.log.Info("library auto-sync scheduled", "interval", interval.String()) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if s.Running() { + s.log.Info("skipping scheduled sync; one is already running") + continue + } + if _, err := s.Sync(ctx, "incremental", "schedule"); err != nil { + if errors.Is(err, ErrNoCredentials) { + // Nobody has signed in yet. Not worth an error-level log every hour. + s.log.Info("skipping scheduled sync; no credentials yet") + continue + } + s.log.Error("scheduled sync failed", "error", err) + } + } + } +} + +// syncItem mirrors the Emby fields promoted to columns. +type syncItem struct { + ID string `json:"Id"` + Name string `json:"Name"` + Type string `json:"Type"` + SeriesID string `json:"SeriesId"` + SeriesName string `json:"SeriesName"` + ProductionYear *int `json:"ProductionYear"` + CommunityRating *float64 `json:"CommunityRating"` + Genres []string `json:"Genres"` + Studios []struct { + Name string `json:"Name"` + } `json:"Studios"` + DateCreated *time.Time `json:"DateCreated"` +} + +// toLibraryItem flattens the columns Postgres filters on while keeping the payload whole. +func toLibraryItem(raw json.RawMessage) (store.LibraryItem, bool) { + var parsed syncItem + if err := json.Unmarshal(raw, &parsed); err != nil || parsed.ID == "" { + return store.LibraryItem{}, false + } + + studios := make([]string, 0, len(parsed.Studios)) + for _, studio := range parsed.Studios { + if name := strings.TrimSpace(studio.Name); name != "" { + studios = append(studios, name) + } + } + genres := make([]string, 0, len(parsed.Genres)) + for _, genre := range parsed.Genres { + if g := strings.TrimSpace(genre); g != "" { + genres = append(genres, g) + } + } + + return store.LibraryItem{ + ID: parsed.ID, + Type: parsed.Type, + Name: parsed.Name, + SeriesID: parsed.SeriesID, + SeriesName: parsed.SeriesName, + ProductionYear: parsed.ProductionYear, + CommunityRating: parsed.CommunityRating, + Genres: genres, + Studios: studios, + DateCreated: parsed.DateCreated, + SearchText: searchText(parsed), + Payload: raw, + }, true +} + +// searchText is what full-text search matches against. Series name is included so +// searching a show finds its episodes. +func searchText(parsed syncItem) string { + parts := []string{parsed.Name} + if parsed.SeriesName != "" && !strings.EqualFold(parsed.SeriesName, parsed.Name) { + parts = append(parts, parsed.SeriesName) + } + if parsed.ProductionYear != nil { + parts = append(parts, strconv.Itoa(*parsed.ProductionYear)) + } + parts = append(parts, parsed.Genres...) + return strings.Join(parts, " ") +} diff --git a/server/internal/library/syncer_test.go b/server/internal/library/syncer_test.go new file mode 100644 index 0000000..609486d --- /dev/null +++ b/server/internal/library/syncer_test.go @@ -0,0 +1,79 @@ +package library + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestToLibraryItemFlattensColumnsAndKeepsPayload(t *testing.T) { + raw := json.RawMessage(`{ + "Id":"42","Name":"Arrival","Type":"Movie","ProductionYear":2016, + "CommunityRating":7.9,"Genres":["Science Fiction"," Drama "], + "Studios":[{"Name":"Paramount"},{"Name":" "}], + "DateCreated":"2024-03-01T10:00:00Z", + "ImageTags":{"Primary":"abc"} + }`) + + item, ok := toLibraryItem(raw) + if !ok { + t.Fatal("expected the item to parse") + } + if item.ID != "42" || item.Name != "Arrival" || item.Type != "Movie" { + t.Fatalf("unexpected columns: %+v", item) + } + if *item.ProductionYear != 2016 || *item.CommunityRating != 7.9 { + t.Fatalf("unexpected numbers: %+v", item) + } + // Whitespace-only studio names are dropped, real ones trimmed. + if len(item.Studios) != 1 || item.Studios[0] != "Paramount" { + t.Fatalf("unexpected studios: %v", item.Studios) + } + if len(item.Genres) != 2 || item.Genres[1] != "Drama" { + t.Fatalf("genres should be trimmed: %v", item.Genres) + } + if item.DateCreated == nil || item.DateCreated.Year() != 2024 { + t.Fatalf("unexpected date: %v", item.DateCreated) + } + // The payload must survive byte-identical: it is what the TV receives, and it holds + // fields (image tags, overview) that no column models. + if string(item.Payload) != string(raw) { + t.Fatal("payload was altered") + } +} + +func TestToLibraryItemRejectsUnusableRows(t *testing.T) { + for name, raw := range map[string]string{ + "malformed": `{"Id":`, + "no id": `{"Name":"Nameless"}`, + } { + if _, ok := toLibraryItem(json.RawMessage(raw)); ok { + t.Fatalf("%s should have been rejected", name) + } + } +} + +func TestSearchTextIncludesSeriesNameSoEpisodesAreFindable(t *testing.T) { + year := 2022 + text := searchText(syncItem{ + Name: "Good News About Hell", + Type: "Episode", + SeriesName: "Severance", + ProductionYear: &year, + Genres: []string{"Drama", "Thriller"}, + }) + + for _, want := range []string{"Good News About Hell", "Severance", "2022", "Drama"} { + if !strings.Contains(text, want) { + t.Fatalf("search text %q is missing %q", text, want) + } + } +} + +func TestSearchTextDoesNotRepeatTheTitleForAMovie(t *testing.T) { + text := searchText(syncItem{Name: "Dune", Type: "Movie", SeriesName: "Dune"}) + + if strings.Count(strings.ToLower(text), "dune") != 1 { + t.Fatalf("title should appear once, got %q", text) + } +} diff --git a/server/internal/recommend/engine.go b/server/internal/recommend/engine.go new file mode 100644 index 0000000..1fc9191 --- /dev/null +++ b/server/internal/recommend/engine.go @@ -0,0 +1,270 @@ +package recommend + +import ( + "context" + "encoding/json" + "log/slog" + "net/url" + "strconv" + "strings" + "sync" + + "github.com/ponzischeme89/memby/server/internal/emby" +) + +// Row is one horizontal strip on the TV home screen. +type Row struct { + ID string `json:"id"` + Title string `json:"title"` + Kind string `json:"kind"` + Items []json.RawMessage `json:"items"` +} + +// Source is the slice of the Emby client this package needs, narrowed so tests can +// supply a fake without a server. +type Source interface { + Items(ctx context.Context, cred emby.Credentials, params url.Values) (*emby.ItemsResult, error) + Similar(ctx context.Context, cred emby.Credentials, itemID string, params url.Values) (*emby.ItemsResult, error) +} + +// LibrarySource is the imported catalogue. When present, the candidate pool comes from +// Postgres instead of Emby, which takes the rebuild off Emby entirely. +type LibrarySource interface { + LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error) +} + +type Engine struct { + source Source + log *slog.Logger + + // Library is optional; nil (or an empty library) falls back to querying Emby. + Library LibrarySource + + // MinRowItems is the shortest row worth showing. A two-item "Recommended" strip + // looks broken next to full rows, so short rows are dropped entirely. + MinRowItems int + // MaxSimilarRows caps "Because you watched …" rows so the home screen stays a home + // screen rather than a wall of near-duplicates. + MaxSimilarRows int + RowSize int +} + +func NewEngine(source Source, log *slog.Logger) *Engine { + return &Engine{ + source: source, + log: log, + MinRowItems: 4, + MaxSimilarRows: 2, + RowSize: 20, + } +} + +const ( + historyFields = "Genres,Studios,CommunityRating,SeriesName,ProductionYear,RunTimeTicks" + candidateFields = "Genres,Studios,CommunityRating,ProductionYear,RunTimeTicks,PrimaryImageAspectRatio" + rowImageTypes = "Backdrop,Primary,Logo" +) + +// BuildRows produces the recommendation rows for one user. +// +// Cost is a handful of Emby queries, which is why callers cache the result rather than +// computing it on every home load. +func (e *Engine) BuildRows(ctx context.Context, cred emby.Credentials) ([]Row, error) { + history, favorites, err := e.gatherSignals(ctx, cred) + if err != nil { + return nil, err + } + + profile := BuildProfile(history, favorites) + if profile.IsEmpty() { + // A brand-new user has nothing to recommend from. No rows is the honest answer. + return nil, nil + } + + rows := make([]Row, 0, e.MaxSimilarRows+1) + for _, seed := range e.seedsFor(profile) { + row, ok := e.similarRow(ctx, cred, profile, seed) + if ok { + rows = append(rows, row) + } + } + + if row, ok := e.historyRow(ctx, cred, profile); ok { + rows = append(rows, row) + } + return rows, nil +} + +// gatherSignals reads what the user has watched and favourited, in parallel. +func (e *Engine) gatherSignals(ctx context.Context, cred emby.Credentials) (history, favorites []Item, err error) { + var ( + wg sync.WaitGroup + mu sync.Mutex + firstErr error + ) + + fetch := func(dest *[]Item, params url.Values) { + wg.Add(1) + go func() { + defer wg.Done() + result, fetchErr := e.source.Items(ctx, cred, params) + mu.Lock() + defer mu.Unlock() + if fetchErr != nil { + if firstErr == nil { + firstErr = fetchErr + } + return + } + *dest = Decode(result.Items) + }() + } + + // In-progress titles are the strongest signal available, so they lead the history + // list and pick up the heaviest recency weights. + var resumable, played []Item + fetch(&resumable, url.Values{ + "Filters": {"IsResumable"}, + "IncludeItemTypes": {"Movie,Episode"}, + "Recursive": {"true"}, + "SortBy": {"DatePlayed"}, + "SortOrder": {"Descending"}, + "Limit": {"20"}, + "Fields": {historyFields}, + "EnableUserData": {"true"}, + "EnableImages": {"false"}, + }) + fetch(&played, url.Values{ + "Filters": {"IsPlayed"}, + "IncludeItemTypes": {"Movie,Episode"}, + "Recursive": {"true"}, + "SortBy": {"DatePlayed"}, + "SortOrder": {"Descending"}, + "Limit": {"60"}, + "Fields": {historyFields}, + "EnableUserData": {"true"}, + "EnableImages": {"false"}, + }) + fetch(&favorites, url.Values{ + "Filters": {"IsFavorite"}, + "IncludeItemTypes": {"Movie,Series"}, + "Recursive": {"true"}, + "SortBy": {"SortName"}, + "Limit": {"40"}, + "Fields": {historyFields}, + "EnableUserData": {"true"}, + "EnableImages": {"false"}, + }) + + wg.Wait() + if firstErr != nil { + return nil, nil, firstErr + } + return append(resumable, played...), favorites, nil +} + +// libraryCandidates reads the pool from the imported library. Returns ok=false when +// there is no library, it is empty, or it errors — every one of which means "ask Emby". +func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item, bool) { + if e.Library == nil { + return nil, false + } + raws, err := e.Library.LibraryCandidates(ctx, genres, e.RowSize*6) + if err != nil { + e.log.Warn("library candidates failed; falling back to emby", "error", err) + return nil, false + } + if len(raws) == 0 { + return nil, false + } + return Decode(raws), true +} + +func (e *Engine) seedsFor(profile Profile) []Seed { + if len(profile.Seeds) <= e.MaxSimilarRows { + return profile.Seeds + } + return profile.Seeds[:e.MaxSimilarRows] +} + +// similarRow asks Emby what resembles a title the user just watched. Emby's own +// similarity scoring beats anything computed here, so this only filters out the seen. +func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile Profile, seed Seed) (Row, bool) { + result, err := e.source.Similar(ctx, cred, seed.ID, url.Values{ + "UserId": {cred.UserID}, + "Limit": {strconv.Itoa(e.RowSize * 2)}, + "Fields": {candidateFields}, + "ImageTypeLimit": {"1"}, + "EnableImageTypes": {rowImageTypes}, + "EnableUserData": {"true"}, + }) + if err != nil { + // One dead row should never sink the home screen. + e.log.Warn("similar lookup failed", "seed", seed.ID, "error", err) + return Row{}, false + } + + items := FilterUnseen(profile, Decode(result.Items), e.RowSize) + if len(items) < e.MinRowItems { + return Row{}, false + } + return Row{ + ID: "similar:" + seed.ID, + Title: "Because you watched " + seed.Name, + Kind: "similar", + Items: Raws(items), + }, true +} + +// historyRow is the genre-affinity row: unwatched titles from the genres the user has +// been spending time in, ranked by how closely they match the whole profile. +func (e *Engine) historyRow(ctx context.Context, cred emby.Credentials, profile Profile) (Row, bool) { + genres := profile.TopGenres(3) + if len(genres) == 0 { + return Row{}, false + } + + if candidates, ok := e.libraryCandidates(ctx, genres); ok { + items := Rank(profile, candidates, e.RowSize) + if len(items) < e.MinRowItems { + return Row{}, false + } + return Row{ + ID: "recommended", + Title: "Recommended from your watching history", + Kind: "recommended", + Items: Raws(items), + }, true + } + + // Emby treats "|" as OR in a Genres filter, so one query covers every top genre. + result, err := e.source.Items(ctx, cred, url.Values{ + "IncludeItemTypes": {"Movie,Series"}, + "Recursive": {"true"}, + "Filters": {"IsUnplayed"}, + "Genres": {strings.Join(genres, "|")}, + "SortBy": {"CommunityRating"}, + "SortOrder": {"Descending"}, + "Limit": {"120"}, + "Fields": {candidateFields}, + "ImageTypeLimit": {"1"}, + "EnableImages": {"true"}, + "EnableImageTypes": {rowImageTypes}, + "EnableUserData": {"true"}, + }) + if err != nil { + e.log.Warn("recommendation candidates failed", "error", err) + return Row{}, false + } + + items := Rank(profile, Decode(result.Items), e.RowSize) + if len(items) < e.MinRowItems { + return Row{}, false + } + return Row{ + ID: "recommended", + Title: "Recommended from your watching history", + Kind: "recommended", + Items: Raws(items), + }, true +} diff --git a/server/internal/recommend/engine_test.go b/server/internal/recommend/engine_test.go new file mode 100644 index 0000000..2bdde55 --- /dev/null +++ b/server/internal/recommend/engine_test.go @@ -0,0 +1,230 @@ +package recommend + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/url" + "strings" + "sync" + "testing" + + "github.com/ponzischeme89/memby/server/internal/emby" +) + +// fakeSource records the queries the engine makes and replays canned answers. +type fakeSource struct { + mu sync.Mutex + + itemsByFilter map[string][]json.RawMessage + similar map[string][]json.RawMessage + itemsErr error + similarErr error + + genreQueries []string + similarSeeds []string +} + +func (f *fakeSource) Items(_ context.Context, _ emby.Credentials, params url.Values) (*emby.ItemsResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.itemsErr != nil { + return nil, f.itemsErr + } + if genres := params.Get("Genres"); genres != "" { + f.genreQueries = append(f.genreQueries, genres) + } + key := params.Get("Filters") + return &emby.ItemsResult{Items: f.itemsByFilter[key]}, nil +} + +func (f *fakeSource) Similar(_ context.Context, _ emby.Credentials, itemID string, _ url.Values) (*emby.ItemsResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.similarSeeds = append(f.similarSeeds, itemID) + if f.similarErr != nil { + return nil, f.similarErr + } + return &emby.ItemsResult{Items: f.similar[itemID]}, nil +} + +func raw(id, name, itemType string, genres ...string) json.RawMessage { + quoted := make([]string, 0, len(genres)) + for _, g := range genres { + quoted = append(quoted, `"`+g+`"`) + } + return json.RawMessage(`{"Id":"` + id + `","Name":"` + name + `","Type":"` + itemType + + `","Genres":[` + strings.Join(quoted, ",") + `],"CommunityRating":7.5}`) +} + +func testEngine(source Source) *Engine { + engine := NewEngine(source, slog.New(slog.NewTextHandler(io.Discard, nil))) + engine.MinRowItems = 2 + return engine +} + +func TestBuildRowsProducesSimilarAndHistoryRows(t *testing.T) { + source := &fakeSource{ + itemsByFilter: map[string][]json.RawMessage{ + "IsResumable": {raw("ep1", "Good News", "Episode", "Drama")}, + "IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")}, + "IsFavorite": {raw("m2", "Arrival", "Movie", "Science Fiction")}, + "IsUnplayed": { + raw("c1", "Blade Runner", "Movie", "Science Fiction"), + raw("c2", "Solaris", "Movie", "Science Fiction"), + raw("c3", "Barbie", "Movie", "Comedy"), + }, + }, + similar: map[string][]json.RawMessage{ + "ep1": {raw("s1", "Devs", "Series", "Drama"), raw("s2", "Mr Robot", "Series", "Drama")}, + "m1": {raw("s3", "Foundation", "Series", "Science Fiction"), raw("s4", "Arrival II", "Movie", "Science Fiction")}, + }, + } + + rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}) + if err != nil { + t.Fatalf("BuildRows: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected 2 similar rows + 1 history row, got %d: %+v", len(rows), rowTitles(rows)) + } + + if rows[0].Kind != "similar" || !strings.HasPrefix(rows[0].Title, "Because you watched ") { + t.Fatalf("unexpected first row: %+v", rows[0]) + } + last := rows[len(rows)-1] + if last.Kind != "recommended" || last.Title != "Recommended from your watching history" { + t.Fatalf("unexpected history row: %+v", last) + } + if last.ID != "recommended" { + t.Fatalf("history row id should be stable, got %q", last.ID) + } +} + +func TestBuildRowsQueriesTheProfilesTopGenres(t *testing.T) { + source := &fakeSource{ + itemsByFilter: map[string][]json.RawMessage{ + "IsPlayed": { + raw("m1", "Dune", "Movie", "Science Fiction"), + raw("m2", "Alien", "Movie", "Science Fiction", "Horror"), + }, + "IsUnplayed": {raw("c1", "Solaris", "Movie", "Science Fiction")}, + }, + } + + if _, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}); err != nil { + t.Fatalf("BuildRows: %v", err) + } + + if len(source.genreQueries) != 1 { + t.Fatalf("expected a single OR'd genre query, got %v", source.genreQueries) + } + // Emby reads "|" as OR, so one query covers every top genre. + if !strings.HasPrefix(source.genreQueries[0], "Science Fiction") { + t.Fatalf("heaviest genre should lead the query, got %q", source.genreQueries[0]) + } +} + +func TestBuildRowsExcludesAlreadyWatchedFromSimilarRow(t *testing.T) { + source := &fakeSource{ + itemsByFilter: map[string][]json.RawMessage{ + "IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")}, + }, + similar: map[string][]json.RawMessage{ + // Emby suggests something the user already finished; it must not appear. + "m1": {raw("m1", "Dune", "Movie", "Science Fiction"), raw("s1", "Foundation", "Series", "Science Fiction")}, + }, + } + engine := testEngine(source) + engine.MinRowItems = 1 + + rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"}) + if err != nil { + t.Fatalf("BuildRows: %v", err) + } + + for _, row := range rows { + for _, item := range row.Items { + if strings.Contains(string(item), `"Id":"m1"`) { + t.Fatalf("row %q contained an already-watched item", row.ID) + } + } + } +} + +func TestBuildRowsDropsRowsShorterThanTheMinimum(t *testing.T) { + source := &fakeSource{ + itemsByFilter: map[string][]json.RawMessage{ + "IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")}, + "IsUnplayed": {raw("c1", "Solaris", "Movie", "Science Fiction")}, + }, + similar: map[string][]json.RawMessage{ + "m1": {raw("s1", "Foundation", "Series", "Science Fiction")}, + }, + } + engine := testEngine(source) + engine.MinRowItems = 5 + + rows, err := engine.BuildRows(context.Background(), emby.Credentials{UserID: "u1"}) + if err != nil { + t.Fatalf("BuildRows: %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected short rows to be dropped, got %v", rowTitles(rows)) + } +} + +func TestBuildRowsReturnsNothingForAUserWithNoHistory(t *testing.T) { + source := &fakeSource{itemsByFilter: map[string][]json.RawMessage{}} + + rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "new"}) + if err != nil { + t.Fatalf("BuildRows: %v", err) + } + if len(rows) != 0 { + t.Fatalf("a new user should get no rows, got %v", rowTitles(rows)) + } + if len(source.similarSeeds) != 0 { + t.Fatal("no seeds means no similarity lookups should be attempted") + } +} + +// A failing similarity lookup is one dead row, not a dead home screen. +func TestBuildRowsSurvivesASimilarLookupFailure(t *testing.T) { + source := &fakeSource{ + itemsByFilter: map[string][]json.RawMessage{ + "IsPlayed": {raw("m1", "Dune", "Movie", "Science Fiction")}, + "IsUnplayed": { + raw("c1", "Solaris", "Movie", "Science Fiction"), + raw("c2", "Blade Runner", "Movie", "Science Fiction"), + }, + }, + similarErr: errors.New("emby is unwell"), + } + + rows, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}) + if err != nil { + t.Fatalf("BuildRows should not fail: %v", err) + } + if len(rows) != 1 || rows[0].Kind != "recommended" { + t.Fatalf("expected the history row to survive, got %v", rowTitles(rows)) + } +} + +func TestBuildRowsFailsWhenHistoryCannotBeRead(t *testing.T) { + source := &fakeSource{itemsErr: errors.New("emby down")} + + if _, err := testEngine(source).BuildRows(context.Background(), emby.Credentials{UserID: "u1"}); err == nil { + t.Fatal("expected an error when the history queries fail") + } +} + +func rowTitles(rows []Row) []string { + out := make([]string, 0, len(rows)) + for _, row := range rows { + out = append(out, row.Title) + } + return out +} diff --git a/server/internal/recommend/profile.go b/server/internal/recommend/profile.go new file mode 100644 index 0000000..f61118d --- /dev/null +++ b/server/internal/recommend/profile.go @@ -0,0 +1,258 @@ +// Package recommend turns a user's Emby watch history into home-screen rows. +// +// The scoring here is deliberately simple and explainable — genre and studio affinity +// weighted by recency, penalised for what the user has already seen. It runs against one +// household's library, where a heavier model would have neither the data to learn from +// nor a way to show its work when a row looks wrong. +package recommend + +import ( + "encoding/json" + "math" + "sort" + "strings" +) + +// recencyDecay is applied per position down the history list. At 0.94, the 12th item +// carries about half the weight of the most recent one, so tastes can shift without the +// rows lagging weeks behind. +const recencyDecay = 0.94 + +// favoriteWeight is what an explicit favourite contributes. Deliberately below a fresh +// play: favouriting is a durable signal, but what someone watched last night is a better +// predictor of what they want tonight. +const favoriteWeight = 0.6 + +// Item is the slice of an Emby item this package reasons about. The raw payload rides +// along so rows can be emitted without re-fetching or re-encoding. +type Item struct { + ID string `json:"Id"` + Name string `json:"Name"` + Type string `json:"Type"` + SeriesID string `json:"SeriesId"` + SeriesName string `json:"SeriesName"` + Genres []string `json:"Genres"` + CommunityRating float64 `json:"CommunityRating"` + Studios []struct { + Name string `json:"Name"` + } `json:"Studios"` + UserData struct { + Played bool `json:"Played"` + PlayCount int `json:"PlayCount"` + PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"` + IsFavorite bool `json:"IsFavorite"` + } `json:"UserData"` + + Raw json.RawMessage `json:"-"` +} + +// Seed is a title recent enough to anchor a "Because you watched …" row. +type Seed struct { + ID string + Name string +} + +// Profile is what the engine learned about one user. +type Profile struct { + GenreWeights map[string]float64 + StudioWeights map[string]float64 + // Seen holds item ids *and* series ids already watched or in progress, so a + // recommendation never suggests something the user is already partway through. + Seen map[string]bool + Seeds []Seed +} + +func (p Profile) IsEmpty() bool { return len(p.GenreWeights) == 0 && len(p.Seeds) == 0 } + +// Decode parses raw Emby items, keeping the original payload attached. +func Decode(raws []json.RawMessage) []Item { + items := make([]Item, 0, len(raws)) + for _, raw := range raws { + var item Item + if err := json.Unmarshal(raw, &item); err != nil || item.ID == "" { + continue + } + item.Raw = raw + items = append(items, item) + } + return items +} + +// BuildProfile weights history by recency and folds in favourites. +// +// history must be ordered most-recent-first; favourites are unordered and all carry the +// same weight. +func BuildProfile(history, favorites []Item) Profile { + profile := Profile{ + GenreWeights: map[string]float64{}, + StudioWeights: map[string]float64{}, + Seen: map[string]bool{}, + } + + seedSeen := map[string]bool{} + for i, item := range history { + weight := math.Pow(recencyDecay, float64(i)) + profile.absorb(item, weight) + + // An episode seeds its series, not itself: "Because you watched Severance" + // reads better than "Because you watched Good News". + seedID, seedName := item.ID, item.Name + if item.SeriesID != "" { + seedID, seedName = item.SeriesID, item.SeriesName + } + if seedID != "" && seedName != "" && !seedSeen[seedID] { + seedSeen[seedID] = true + profile.Seeds = append(profile.Seeds, Seed{ID: seedID, Name: seedName}) + } + } + + for _, item := range favorites { + profile.absorb(item, favoriteWeight) + } + return profile +} + +func (p *Profile) absorb(item Item, weight float64) { + if item.ID != "" { + p.Seen[item.ID] = true + } + if item.SeriesID != "" { + p.Seen[item.SeriesID] = true + } + for _, genre := range item.Genres { + if g := strings.TrimSpace(genre); g != "" { + p.GenreWeights[g] += weight + } + } + for _, studio := range item.Studios { + if s := strings.TrimSpace(studio.Name); s != "" { + // Studio is a weaker signal than genre: people follow what a thing *is* + // more reliably than who made it. + p.StudioWeights[s] += weight * 0.4 + } + } +} + +// TopGenres returns the n heaviest genres, highest first. Ties break alphabetically so +// the Emby query — and therefore the cached row — is stable between calls. +func (p Profile) TopGenres(n int) []string { + type kv struct { + genre string + weight float64 + } + pairs := make([]kv, 0, len(p.GenreWeights)) + for genre, weight := range p.GenreWeights { + pairs = append(pairs, kv{genre, weight}) + } + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].weight != pairs[j].weight { + return pairs[i].weight > pairs[j].weight + } + return pairs[i].genre < pairs[j].genre + }) + if n > len(pairs) { + n = len(pairs) + } + out := make([]string, 0, n) + for _, pair := range pairs[:n] { + out = append(out, pair.genre) + } + return out +} + +// Score rates a candidate against the profile. A negative score means "exclude". +func (p Profile) Score(candidate Item) float64 { + if p.Seen[candidate.ID] { + return -1 + } + if candidate.SeriesID != "" && p.Seen[candidate.SeriesID] { + return -1 + } + if candidate.UserData.Played || candidate.UserData.PlaybackPositionTicks > 0 { + return -1 + } + + var genreScore float64 + for _, genre := range candidate.Genres { + genreScore += p.GenreWeights[strings.TrimSpace(genre)] + } + // Divide by sqrt(genre count) so a title tagged with eight genres cannot outrank a + // focused match simply by touching more of the profile. + if n := len(candidate.Genres); n > 1 { + genreScore /= math.Sqrt(float64(n)) + } + + var studioScore float64 + for _, studio := range candidate.Studios { + studioScore += p.StudioWeights[strings.TrimSpace(studio.Name)] + } + + // A mild quality nudge, capped so a beloved genre still beats a well-rated stranger. + ratingScore := candidate.CommunityRating / 10 * 0.5 + + return genreScore + studioScore + ratingScore +} + +// Rank scores, filters and truncates candidates, dropping duplicates by id. +func Rank(profile Profile, candidates []Item, limit int) []Item { + type scored struct { + item Item + score float64 + } + + seen := map[string]bool{} + ranked := make([]scored, 0, len(candidates)) + for _, candidate := range candidates { + if seen[candidate.ID] { + continue + } + seen[candidate.ID] = true + if score := profile.Score(candidate); score > 0 { + ranked = append(ranked, scored{candidate, score}) + } + } + + sort.SliceStable(ranked, func(i, j int) bool { + if ranked[i].score != ranked[j].score { + return ranked[i].score > ranked[j].score + } + return ranked[i].item.Name < ranked[j].item.Name + }) + + if limit > 0 && len(ranked) > limit { + ranked = ranked[:limit] + } + out := make([]Item, 0, len(ranked)) + for _, entry := range ranked { + out = append(out, entry.item) + } + return out +} + +// FilterUnseen keeps only what the user has not watched, preserving Emby's ordering. +// Used for "Because you watched …", where Emby's own similarity ranking is better than +// anything this package would compute. +func FilterUnseen(profile Profile, candidates []Item, limit int) []Item { + out := make([]Item, 0, len(candidates)) + seen := map[string]bool{} + for _, candidate := range candidates { + if seen[candidate.ID] || profile.Score(candidate) < 0 { + continue + } + seen[candidate.ID] = true + out = append(out, candidate) + if limit > 0 && len(out) >= limit { + break + } + } + return out +} + +// Raws unwraps items back to the payloads the TV will receive. +func Raws(items []Item) []json.RawMessage { + out := make([]json.RawMessage, 0, len(items)) + for _, item := range items { + out = append(out, item.Raw) + } + return out +} diff --git a/server/internal/recommend/profile_test.go b/server/internal/recommend/profile_test.go new file mode 100644 index 0000000..d4fd2ca --- /dev/null +++ b/server/internal/recommend/profile_test.go @@ -0,0 +1,183 @@ +package recommend + +import ( + "encoding/json" + "testing" +) + +func item(id, name, itemType string, genres []string, rating float64) Item { + return Item{ID: id, Name: name, Type: itemType, Genres: genres, CommunityRating: rating} +} + +func episode(id, name, seriesID, seriesName string, genres []string) Item { + it := item(id, name, "Episode", genres, 0) + it.SeriesID = seriesID + it.SeriesName = seriesName + return it +} + +func TestBuildProfileWeightsRecentHistoryHigher(t *testing.T) { + history := []Item{ + item("1", "Newest", "Movie", []string{"Science Fiction"}, 8), + item("2", "Older", "Movie", []string{"Comedy"}, 8), + } + profile := BuildProfile(history, nil) + + if profile.GenreWeights["Science Fiction"] <= profile.GenreWeights["Comedy"] { + t.Fatalf("recent genre should outweigh older: %+v", profile.GenreWeights) + } +} + +func TestBuildProfileSeedsSeriesRatherThanEpisode(t *testing.T) { + history := []Item{ + episode("ep1", "Good News", "sev", "Severance", []string{"Drama"}), + } + profile := BuildProfile(history, nil) + + if len(profile.Seeds) != 1 { + t.Fatalf("expected one seed, got %+v", profile.Seeds) + } + if profile.Seeds[0].ID != "sev" || profile.Seeds[0].Name != "Severance" { + t.Fatalf("expected the series as seed, got %+v", profile.Seeds[0]) + } + // The series must count as seen, or we would recommend a show already in progress. + if !profile.Seen["sev"] { + t.Fatal("series id should be marked seen") + } +} + +func TestBuildProfileDeduplicatesSeeds(t *testing.T) { + history := []Item{ + episode("ep2", "Half Loop", "sev", "Severance", nil), + episode("ep1", "Good News", "sev", "Severance", nil), + item("m1", "Dune", "Movie", nil, 0), + } + profile := BuildProfile(history, nil) + + if len(profile.Seeds) != 2 { + t.Fatalf("expected 2 distinct seeds, got %d: %+v", len(profile.Seeds), profile.Seeds) + } +} + +func TestFavoritesContributeLessThanAFreshPlay(t *testing.T) { + fromHistory := BuildProfile([]Item{item("1", "A", "Movie", []string{"Horror"}, 0)}, nil) + fromFavorite := BuildProfile(nil, []Item{item("2", "B", "Movie", []string{"Horror"}, 0)}) + + if fromFavorite.GenreWeights["Horror"] >= fromHistory.GenreWeights["Horror"] { + t.Fatal("a favourite should weigh less than the most recent play") + } +} + +func TestTopGenresIsDeterministicOnTies(t *testing.T) { + profile := Profile{GenreWeights: map[string]float64{"Western": 1, "Action": 1, "Drama": 2}} + for range 20 { + got := profile.TopGenres(3) + want := []string{"Drama", "Action", "Western"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("unstable ordering: got %v, want %v", got, want) + } + } + } +} + +func TestScoreExcludesWhatTheUserAlreadySaw(t *testing.T) { + profile := BuildProfile([]Item{item("seen", "Seen", "Movie", []string{"Drama"}, 0)}, nil) + + if score := profile.Score(item("seen", "Seen", "Movie", []string{"Drama"}, 8)); score >= 0 { + t.Fatalf("watched item should be excluded, scored %v", score) + } + + inProgress := item("new", "New", "Movie", []string{"Drama"}, 8) + inProgress.UserData.PlaybackPositionTicks = 500 + if score := profile.Score(inProgress); score >= 0 { + t.Fatalf("in-progress item should be excluded, scored %v", score) + } +} + +func TestScoreExcludesEpisodesOfASeriesInProgress(t *testing.T) { + profile := BuildProfile([]Item{episode("ep1", "Pilot", "sev", "Severance", []string{"Drama"})}, nil) + + candidate := episode("ep9", "Finale", "sev", "Severance", []string{"Drama"}) + if score := profile.Score(candidate); score >= 0 { + t.Fatalf("another episode of a watched series should be excluded, scored %v", score) + } +} + +func TestScoreDoesNotRewardGenreStuffing(t *testing.T) { + profile := Profile{ + GenreWeights: map[string]float64{"Drama": 1, "Action": 1, "Comedy": 1, "Horror": 1}, + StudioWeights: map[string]float64{}, + Seen: map[string]bool{}, + } + + focused := item("a", "Focused", "Movie", []string{"Drama"}, 0) + stuffed := item("b", "Stuffed", "Movie", []string{"Drama", "Action", "Comedy", "Horror"}, 0) + + // The stuffed title still scores higher — it genuinely matches more of the profile — + // but the sqrt penalty must keep it from scoring 4x the focused one. + if profile.Score(stuffed) >= 4*profile.Score(focused) { + t.Fatalf("genre stuffing was not penalised: focused=%v stuffed=%v", + profile.Score(focused), profile.Score(stuffed)) + } +} + +func TestRankOrdersByAffinityAndDropsDuplicates(t *testing.T) { + profile := BuildProfile([]Item{item("h", "History", "Movie", []string{"Science Fiction"}, 0)}, nil) + + candidates := []Item{ + item("c1", "Comedy Pick", "Movie", []string{"Comedy"}, 9), + item("c2", "Sci-Fi Pick", "Movie", []string{"Science Fiction"}, 5), + item("c2", "Sci-Fi Pick (dupe)", "Movie", []string{"Science Fiction"}, 5), + item("h", "History", "Movie", []string{"Science Fiction"}, 10), + } + + ranked := Rank(profile, candidates, 10) + + if len(ranked) != 2 { + t.Fatalf("expected 2 results (dupe collapsed, watched dropped), got %d: %+v", len(ranked), ranked) + } + if ranked[0].ID != "c2" { + t.Fatalf("genre affinity should beat a higher rating, got %q first", ranked[0].ID) + } +} + +func TestRankRespectsLimit(t *testing.T) { + profile := Profile{GenreWeights: map[string]float64{"Drama": 1}, Seen: map[string]bool{}} + candidates := make([]Item, 0, 30) + for i := range 30 { + candidates = append(candidates, item(string(rune('a'+i)), "Title", "Movie", []string{"Drama"}, 5)) + } + if got := len(Rank(profile, candidates, 8)); got != 8 { + t.Fatalf("limit not applied: got %d", got) + } +} + +func TestDecodeKeepsRawPayload(t *testing.T) { + raw := json.RawMessage(`{"Id":"1","Name":"Dune","Type":"Movie","Genres":["Science Fiction"],"ImageTags":{"Primary":"abc"}}`) + items := Decode([]json.RawMessage{raw, json.RawMessage(`{"broken":`), json.RawMessage(`{"Name":"no id"}`)}) + + if len(items) != 1 { + t.Fatalf("expected malformed and id-less items to be skipped, got %d", len(items)) + } + // The raw payload must survive untouched: it carries image tags the TV needs and + // that this package never models. + if string(items[0].Raw) != string(raw) { + t.Fatalf("raw payload was altered: %s", items[0].Raw) + } +} + +func TestFilterUnseenPreservesEmbyOrdering(t *testing.T) { + profile := BuildProfile([]Item{item("seen", "Seen", "Movie", nil, 0)}, nil) + candidates := []Item{ + item("seen", "Seen", "Movie", nil, 0), + item("b", "Second", "Movie", nil, 0), + item("a", "First", "Movie", nil, 0), + } + + got := FilterUnseen(profile, candidates, 10) + + if len(got) != 2 || got[0].ID != "b" || got[1].ID != "a" { + t.Fatalf("ordering not preserved: %+v", got) + } +} diff --git a/server/internal/store/analytics.go b/server/internal/store/analytics.go new file mode 100644 index 0000000..1b82400 --- /dev/null +++ b/server/internal/store/analytics.go @@ -0,0 +1,113 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// RowEvent is one reported interaction with a home-screen row. +type RowEvent struct { + OccurredAt time.Time + UserID string + RowID string + RowKind string + Event string + ItemID string + DwellMs int +} + +// Event kinds. Impressions say a row was drawn; focus says the remote actually landed +// on it and for how long; select says something was opened from it. +const ( + RowEventImpression = "impression" + RowEventFocus = "focus" + RowEventSelect = "select" +) + +// RowStat is the aggregate the admin page renders. +type RowStat struct { + RowID string `json:"rowId"` + RowKind string `json:"rowKind"` + Impressions int64 `json:"impressions"` + Focuses int64 `json:"focuses"` + Selects int64 `json:"selects"` + DwellMs int64 `json:"dwellMs"` + Viewers int64 `json:"viewers"` + SelectRate float64 `json:"selectRate"` +} + +func (s *Store) InsertRowEvents(ctx context.Context, events []RowEvent) error { + if len(events) == 0 { + return nil + } + batch := &pgx.Batch{} + for _, event := range events { + batch.Queue(` + INSERT INTO row_events (occurred_at, emby_user_id, row_id, row_kind, event, item_id, dwell_ms) + VALUES ($1,$2,$3,$4,$5,$6,$7)`, + event.OccurredAt, event.UserID, event.RowID, event.RowKind, + event.Event, event.ItemID, event.DwellMs) + } + + results := s.pool.SendBatch(ctx, batch) + defer results.Close() + for range events { + if _, err := results.Exec(); err != nil { + return fmt.Errorf("store: insert row events: %w", err) + } + } + return nil +} + +// RowStats aggregates engagement since a point in time, busiest row first. +// +// Dwell is the interesting number: impressions only say a row was on screen, whereas +// dwell says someone actually stopped there. +func (s *Store) RowStats(ctx context.Context, since time.Time) ([]RowStat, error) { + rows, err := s.pool.Query(ctx, ` + SELECT row_id, + (array_agg(row_kind ORDER BY occurred_at DESC))[1] AS row_kind, + count(*) FILTER (WHERE event = 'impression') AS impressions, + count(*) FILTER (WHERE event = 'focus') AS focuses, + count(*) FILTER (WHERE event = 'select') AS selects, + coalesce(sum(dwell_ms), 0) AS dwell_ms, + count(DISTINCT emby_user_id) AS viewers + FROM row_events + WHERE occurred_at >= $1 + GROUP BY row_id + ORDER BY dwell_ms DESC, impressions DESC`, since) + if err != nil { + return nil, fmt.Errorf("store: row stats: %w", err) + } + defer rows.Close() + + stats := []RowStat{} + for rows.Next() { + var stat RowStat + if err := rows.Scan(&stat.RowID, &stat.RowKind, &stat.Impressions, &stat.Focuses, + &stat.Selects, &stat.DwellMs, &stat.Viewers); err != nil { + return nil, err + } + if stat.Impressions > 0 { + stat.SelectRate = float64(stat.Selects) / float64(stat.Impressions) + } + stats = append(stats, stat) + } + return stats, rows.Err() +} + +// PruneRowEvents drops raw events past their retention window. Aggregates are computed +// at read time, so nothing is preserved once the events go — which is the point: this is +// engagement telemetry for tuning rows, not a permanent record of what people watched. +func (s *Store) PruneRowEvents(ctx context.Context, olderThan time.Duration) (int64, error) { + tag, err := s.pool.Exec(ctx, + `DELETE FROM row_events WHERE occurred_at < now() - $1::interval`, + fmt.Sprintf("%d seconds", int64(olderThan.Seconds()))) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/server/internal/store/library.go b/server/internal/store/library.go new file mode 100644 index 0000000..09ded94 --- /dev/null +++ b/server/internal/store/library.go @@ -0,0 +1,181 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +// LibraryItem is one imported Emby item. Payload is Emby's JSON verbatim; the flat +// columns exist only so Postgres can filter and rank without opening the JSON. +type LibraryItem struct { + ID string + Type string + Name string + SeriesID string + SeriesName string + ProductionYear *int + CommunityRating *float64 + Genres []string + Studios []string + DateCreated *time.Time + SearchText string + Payload json.RawMessage +} + +// LibraryStats is what the admin page shows about the imported library. +type LibraryStats struct { + Total int64 `json:"total"` + ByType map[string]int64 `json:"byType"` + LastSynced *time.Time `json:"lastSynced"` +} + +// UpsertLibraryItems writes a batch, refreshing synced_at on every row it touches. +// +// synced_at doubles as the mark-and-sweep marker: a full import stamps everything it +// sees, then deletes whatever kept an older stamp. +func (s *Store) UpsertLibraryItems(ctx context.Context, items []LibraryItem, syncedAt time.Time) (int64, error) { + if len(items) == 0 { + return 0, nil + } + + batch := &pgx.Batch{} + for _, item := range items { + batch.Queue(` + INSERT INTO library_items ( + id, type, name, series_id, series_name, production_year, community_rating, + genres, studios, date_created, search_text, payload, synced_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::jsonb,$13) + ON CONFLICT (id) DO UPDATE SET + type = EXCLUDED.type, + name = EXCLUDED.name, + series_id = EXCLUDED.series_id, + series_name = EXCLUDED.series_name, + production_year = EXCLUDED.production_year, + community_rating = EXCLUDED.community_rating, + genres = EXCLUDED.genres, + studios = EXCLUDED.studios, + date_created = EXCLUDED.date_created, + search_text = EXCLUDED.search_text, + payload = EXCLUDED.payload, + synced_at = EXCLUDED.synced_at`, + item.ID, item.Type, item.Name, item.SeriesID, item.SeriesName, + item.ProductionYear, item.CommunityRating, item.Genres, item.Studios, + item.DateCreated, item.SearchText, string(item.Payload), syncedAt) + } + + results := s.pool.SendBatch(ctx, batch) + defer results.Close() + + var written int64 + for range items { + tag, err := results.Exec() + if err != nil { + return written, fmt.Errorf("store: upsert library items: %w", err) + } + written += tag.RowsAffected() + } + return written, nil +} + +// DeleteLibraryItemsBefore removes anything a full import did not touch — items deleted +// from Emby since the last run. +func (s *Store) DeleteLibraryItemsBefore(ctx context.Context, cutoff time.Time) (int64, error) { + tag, err := s.pool.Exec(ctx, `DELETE FROM library_items WHERE synced_at < $1`, cutoff) + if err != nil { + return 0, fmt.Errorf("store: prune library: %w", err) + } + return tag.RowsAffected(), nil +} + +// SearchLibrary answers from the imported library rather than Emby. +// +// Full-text match first, with a trailing ILIKE so partial words ("sever") still hit +// before someone finishes typing on a remote. +func (s *Store) SearchLibrary(ctx context.Context, term string, limit int) ([]json.RawMessage, error) { + trimmed := strings.TrimSpace(term) + if trimmed == "" { + return nil, nil + } + rows, err := s.pool.Query(ctx, ` + SELECT payload + FROM library_items + WHERE search_tsv @@ plainto_tsquery('simple', $1) + OR search_text ILIKE '%' || $1 || '%' + ORDER BY + ts_rank(search_tsv, plainto_tsquery('simple', $1)) DESC, + (lower(name) = lower($1)) DESC, + community_rating DESC NULLS LAST, + name ASC + LIMIT $2`, trimmed, limit) + if err != nil { + return nil, fmt.Errorf("store: search library: %w", err) + } + return collectPayloads(rows) +} + +// LibraryCandidates returns unwatched-agnostic candidates in the given genres, for the +// recommendation engine. User state is applied by the caller, which is the only place +// that knows it. +func (s *Store) LibraryCandidates(ctx context.Context, genres []string, limit int) ([]json.RawMessage, error) { + if len(genres) == 0 { + return nil, nil + } + rows, err := s.pool.Query(ctx, ` + SELECT payload + FROM library_items + WHERE type IN ('Movie', 'Series') + AND genres && $1 + ORDER BY community_rating DESC NULLS LAST, date_created DESC NULLS LAST + LIMIT $2`, genres, limit) + if err != nil { + return nil, fmt.Errorf("store: library candidates: %w", err) + } + return collectPayloads(rows) +} + +func (s *Store) LibraryStats(ctx context.Context) (LibraryStats, error) { + stats := LibraryStats{ByType: map[string]int64{}} + + rows, err := s.pool.Query(ctx, `SELECT type, count(*) FROM library_items GROUP BY type`) + if err != nil { + return stats, fmt.Errorf("store: library stats: %w", err) + } + defer rows.Close() + for rows.Next() { + var itemType string + var count int64 + if err := rows.Scan(&itemType, &count); err != nil { + return stats, err + } + stats.ByType[itemType] = count + stats.Total += count + } + if err := rows.Err(); err != nil { + return stats, err + } + + var lastSynced *time.Time + if err := s.pool.QueryRow(ctx, `SELECT max(synced_at) FROM library_items`).Scan(&lastSynced); err != nil { + return stats, err + } + stats.LastSynced = lastSynced + return stats, nil +} + +func collectPayloads(rows pgx.Rows) ([]json.RawMessage, error) { + defer rows.Close() + out := []json.RawMessage{} + for rows.Next() { + var payload []byte + if err := rows.Scan(&payload); err != nil { + return nil, err + } + out = append(out, json.RawMessage(payload)) + } + return out, rows.Err() +} diff --git a/server/internal/store/schema.sql b/server/internal/store/schema.sql new file mode 100644 index 0000000..e314095 --- /dev/null +++ b/server/internal/store/schema.sql @@ -0,0 +1,88 @@ +-- Gateway sessions: one row per signed-in TV. +-- +-- token_hash is SHA-256 of the bearer token handed to the device, so a database dump +-- does not hand over working gateway tokens. emby_token IS the live upstream token and +-- is stored as-is: treat this volume as a secret store. +CREATE TABLE IF NOT EXISTS sessions ( + token_hash BYTEA PRIMARY KEY, + emby_user_id TEXT NOT NULL, + emby_token TEXT NOT NULL, + username TEXT NOT NULL, + server_id TEXT NOT NULL DEFAULT '', + device_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS sessions_emby_user_idx ON sessions (emby_user_id); +CREATE INDEX IF NOT EXISTS sessions_last_seen_idx ON sessions (last_seen_at); + +-- The imported library. +-- +-- payload is Emby's item JSON verbatim, so rows served from here are byte-identical to +-- rows served live. Deliberately holds NO per-user state: everything is imported with +-- EnableUserData=false, because one household shares this table and watched/favourite +-- flags are not shareable. Anything user-specific still comes from Emby live. +CREATE TABLE IF NOT EXISTS library_items ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + series_id TEXT NOT NULL DEFAULT '', + series_name TEXT NOT NULL DEFAULT '', + production_year INT, + community_rating REAL, + genres TEXT[] NOT NULL DEFAULT '{}', + studios TEXT[] NOT NULL DEFAULT '{}', + date_created TIMESTAMPTZ, + search_text TEXT NOT NULL DEFAULT '', + payload JSONB NOT NULL, + synced_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- 'simple' rather than 'english': film titles are proper nouns, and stemming + -- "Arrival" into "arriv" helps nobody. + search_tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', search_text)) STORED +); + +CREATE INDEX IF NOT EXISTS library_items_search_idx ON library_items USING GIN (search_tsv); +CREATE INDEX IF NOT EXISTS library_items_genres_idx ON library_items USING GIN (genres); +CREATE INDEX IF NOT EXISTS library_items_type_created_idx ON library_items (type, date_created DESC); +CREATE INDEX IF NOT EXISTS library_items_synced_idx ON library_items (synced_at); + +-- One row per import, so the admin page can show what happened and when. +CREATE TABLE IF NOT EXISTS sync_runs ( + id BIGSERIAL PRIMARY KEY, + kind TEXT NOT NULL, -- full | incremental + trigger TEXT NOT NULL DEFAULT 'schedule', -- schedule | manual | startup + status TEXT NOT NULL, -- running | success | failed + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finished_at TIMESTAMPTZ, + items_seen INT NOT NULL DEFAULT 0, + items_upserted INT NOT NULL DEFAULT 0, + items_removed INT NOT NULL DEFAULT 0, + error TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX IF NOT EXISTS sync_runs_started_idx ON sync_runs (started_at DESC); + +-- Small key/value store for operator switches (currently just maintenance mode). Kept in +-- Postgres rather than memory so a restart cannot silently bring the app back up. +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Row-level engagement. One row per reported event; aggregation happens at read time, +-- which is fine at household scale and keeps the write path trivial. +CREATE TABLE IF NOT EXISTS row_events ( + id BIGSERIAL PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + emby_user_id TEXT NOT NULL, + row_id TEXT NOT NULL, + row_kind TEXT NOT NULL DEFAULT '', + event TEXT NOT NULL, -- impression | focus | select + item_id TEXT NOT NULL DEFAULT '', + dwell_ms INT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS row_events_time_idx ON row_events (occurred_at DESC); +CREATE INDEX IF NOT EXISTS row_events_row_idx ON row_events (row_id, occurred_at DESC); diff --git a/server/internal/store/settings.go b/server/internal/store/settings.go new file mode 100644 index 0000000..95c41a5 --- /dev/null +++ b/server/internal/store/settings.go @@ -0,0 +1,80 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// MaintenanceKey is the app_settings row backing maintenance mode. +const MaintenanceKey = "maintenance" + +// Maintenance is the operator switch that takes Memby down independently of Emby. +// +// Deliberately durable: a restart must not quietly bring the app back up while someone +// is still working on it. +type Maintenance struct { + Enabled bool `json:"enabled"` + Message string `json:"message"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// DefaultMaintenanceMessage is shown on the TV when the operator did not write one. +const DefaultMaintenanceMessage = "Memby is down for maintenance. Try again shortly." + +func (s *Store) Maintenance(ctx context.Context) (Maintenance, error) { + var raw []byte + err := s.pool.QueryRow(ctx, `SELECT value FROM app_settings WHERE key = $1`, MaintenanceKey).Scan(&raw) + if errors.Is(err, pgx.ErrNoRows) { + return Maintenance{}, nil + } + if err != nil { + return Maintenance{}, fmt.Errorf("store: read maintenance: %w", err) + } + + var state Maintenance + if err := json.Unmarshal(raw, &state); err != nil { + return Maintenance{}, fmt.Errorf("store: decode maintenance: %w", err) + } + return state, nil +} + +func (s *Store) SetMaintenance(ctx context.Context, state Maintenance) error { + state.UpdatedAt = time.Now().UTC() + raw, err := json.Marshal(state) + if err != nil { + return err + } + _, err = s.pool.Exec(ctx, ` + INSERT INTO app_settings (key, value, updated_at) + VALUES ($1, $2::jsonb, now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now()`, + MaintenanceKey, string(raw)) + if err != nil { + return fmt.Errorf("store: write maintenance: %w", err) + } + return nil +} + +// NewestSession is the fallback credential for the library import: whichever TV signed +// in most recently. It means a fresh deployment can import without configuring a +// service account, at the cost of the import stopping if that user is ever removed. +func (s *Store) NewestSession(ctx context.Context) (Session, error) { + var sess Session + err := s.pool.QueryRow(ctx, ` + SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, last_seen_at + FROM sessions ORDER BY last_seen_at DESC LIMIT 1`). + Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username, + &sess.ServerID, &sess.DeviceID, &sess.LastSeenAt) + if errors.Is(err, pgx.ErrNoRows) { + return Session{}, ErrNotFound + } + if err != nil { + return Session{}, fmt.Errorf("store: newest session: %w", err) + } + return sess, nil +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go new file mode 100644 index 0000000..6a29295 --- /dev/null +++ b/server/internal/store/store.go @@ -0,0 +1,113 @@ +// Package store persists gateway sessions in Postgres. +package store + +import ( + "context" + _ "embed" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed schema.sql +var schema string + +// ErrNotFound is returned when a token does not match a live session. +var ErrNotFound = errors.New("store: session not found") + +type Session struct { + TokenHash []byte + EmbyUserID string + EmbyToken string + Username string + ServerID string + DeviceID string + LastSeenAt time.Time +} + +type Store struct { + pool *pgxpool.Pool +} + +func Open(ctx context.Context, databaseURL string) (*Store, error) { + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return nil, fmt.Errorf("store: connect: %w", err) + } + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("store: ping: %w", err) + } + return &Store{pool: pool}, nil +} + +func (s *Store) Close() { s.pool.Close() } + +func (s *Store) Ping(ctx context.Context) error { return s.pool.Ping(ctx) } + +// Migrate applies the schema. It is idempotent, so it runs on every boot. +func (s *Store) Migrate(ctx context.Context) error { + if _, err := s.pool.Exec(ctx, schema); err != nil { + return fmt.Errorf("store: migrate: %w", err) + } + return nil +} + +func (s *Store) CreateSession(ctx context.Context, sess Session) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO sessions (token_hash, emby_user_id, emby_token, username, server_id, device_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (token_hash) DO UPDATE SET + emby_token = EXCLUDED.emby_token, + username = EXCLUDED.username, + server_id = EXCLUDED.server_id, + device_id = EXCLUDED.device_id, + last_seen_at = now()`, + sess.TokenHash, sess.EmbyUserID, sess.EmbyToken, sess.Username, sess.ServerID, sess.DeviceID) + if err != nil { + return fmt.Errorf("store: create session: %w", err) + } + return nil +} + +func (s *Store) SessionByTokenHash(ctx context.Context, hash []byte) (Session, error) { + var sess Session + err := s.pool.QueryRow(ctx, ` + SELECT token_hash, emby_user_id, emby_token, username, server_id, device_id, last_seen_at + FROM sessions WHERE token_hash = $1`, hash). + Scan(&sess.TokenHash, &sess.EmbyUserID, &sess.EmbyToken, &sess.Username, + &sess.ServerID, &sess.DeviceID, &sess.LastSeenAt) + if errors.Is(err, pgx.ErrNoRows) { + return Session{}, ErrNotFound + } + if err != nil { + return Session{}, fmt.Errorf("store: load session: %w", err) + } + return sess, nil +} + +// Touch records activity. Cheap enough to call on the auth path, and it is what the +// idle-expiry sweep reads. +func (s *Store) Touch(ctx context.Context, hash []byte) error { + _, err := s.pool.Exec(ctx, `UPDATE sessions SET last_seen_at = now() WHERE token_hash = $1`, hash) + return err +} + +func (s *Store) DeleteSession(ctx context.Context, hash []byte) error { + _, err := s.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, hash) + return err +} + +// DeleteIdleSessions retires tokens unused for longer than idle, returning how many went. +func (s *Store) DeleteIdleSessions(ctx context.Context, idle time.Duration) (int64, error) { + tag, err := s.pool.Exec(ctx, + `DELETE FROM sessions WHERE last_seen_at < now() - $1::interval`, + fmt.Sprintf("%d seconds", int64(idle.Seconds()))) + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/server/internal/store/sync.go b/server/internal/store/sync.go new file mode 100644 index 0000000..722c2f5 --- /dev/null +++ b/server/internal/store/sync.go @@ -0,0 +1,101 @@ +package store + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" +) + +// SyncRun records one library import. +type SyncRun struct { + ID int64 `json:"id"` + Kind string `json:"kind"` + Trigger string `json:"trigger"` + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt"` + ItemsSeen int `json:"itemsSeen"` + ItemsUpserted int `json:"itemsUpserted"` + ItemsRemoved int `json:"itemsRemoved"` + Error string `json:"error"` +} + +const ( + SyncStatusRunning = "running" + SyncStatusSuccess = "success" + SyncStatusFailed = "failed" +) + +func (s *Store) StartSyncRun(ctx context.Context, kind, trigger string) (int64, error) { + var id int64 + err := s.pool.QueryRow(ctx, + `INSERT INTO sync_runs (kind, trigger, status) VALUES ($1, $2, $3) RETURNING id`, + kind, trigger, SyncStatusRunning).Scan(&id) + if err != nil { + return 0, fmt.Errorf("store: start sync run: %w", err) + } + return id, nil +} + +func (s *Store) FinishSyncRun(ctx context.Context, id int64, run SyncRun) error { + _, err := s.pool.Exec(ctx, ` + UPDATE sync_runs + SET status = $2, finished_at = now(), items_seen = $3, + items_upserted = $4, items_removed = $5, error = $6 + WHERE id = $1`, + id, run.Status, run.ItemsSeen, run.ItemsUpserted, run.ItemsRemoved, run.Error) + if err != nil { + return fmt.Errorf("store: finish sync run: %w", err) + } + return nil +} + +func (s *Store) RecentSyncRuns(ctx context.Context, limit int) ([]SyncRun, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, kind, trigger, status, started_at, finished_at, + items_seen, items_upserted, items_removed, error + FROM sync_runs ORDER BY started_at DESC LIMIT $1`, limit) + if err != nil { + return nil, fmt.Errorf("store: recent sync runs: %w", err) + } + defer rows.Close() + + runs := []SyncRun{} + for rows.Next() { + var run SyncRun + if err := rows.Scan(&run.ID, &run.Kind, &run.Trigger, &run.Status, &run.StartedAt, + &run.FinishedAt, &run.ItemsSeen, &run.ItemsUpserted, &run.ItemsRemoved, &run.Error); err != nil { + return nil, err + } + runs = append(runs, run) + } + return runs, rows.Err() +} + +// LastSuccessfulSyncAt is the watermark an incremental import asks Emby about: "what has +// changed since?" Nil means nothing has ever completed, so a full import is required. +func (s *Store) LastSuccessfulSyncAt(ctx context.Context) (*time.Time, error) { + var at *time.Time + err := s.pool.QueryRow(ctx, + `SELECT max(started_at) FROM sync_runs WHERE status = $1`, SyncStatusSuccess).Scan(&at) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("store: last successful sync: %w", err) + } + return at, nil +} + +// MarkStaleRunsFailed cleans up runs left "running" by a crash or a restart mid-import. +func (s *Store) MarkStaleRunsFailed(ctx context.Context) error { + _, err := s.pool.Exec(ctx, ` + UPDATE sync_runs + SET status = $1, finished_at = now(), + error = 'interrupted — the gateway restarted while this import was running' + WHERE status = $2`, SyncStatusFailed, SyncStatusRunning) + return err +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..3af586c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Memby" +include(":app") +include(":benchmark")