commit 2ce405c5404d579c604a85d748756397e979222a Author: ponzischeme89 Date: Mon Jul 27 08:16:20 2026 +1200 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 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 0000000..ca84b69 Binary files /dev/null and b/app/logo.png differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f6cc807 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,8 @@ +# kotlinx.serialization keeps @Serializable metadata via generated serializers. +-keepattributes *Annotation*, InnerClasses +-dontnote kotlinx.serialization.** +-keepclassmembers class **$$serializer { *; } +-keepclasseswithmembers class com.mattcohen.embyscreensaver.data.model.** { + kotlinx.serialization.KSerializer serializer(...); +} +-keep,includedescriptorclasses class com.mattcohen.embyscreensaver.data.model.**$$serializer { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e4002dc --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 0000000..ca84b69 Binary files /dev/null and b/app/src/main/res/drawable/emby_logo.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..8cd98b2 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + 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 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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")