Memby v0.1.53: Android TV client plus gateway

Android TV client for Emby (Kotlin, Compose for TV) and the Memby gateway
(Go, Postgres, Redis) that fronts it.

Client:
- Setup, profiles, home rows, Media3 playback, system screensaver (Dream)
- Backend chosen at build time: gateway when memby.gatewayUrl is set,
  otherwise direct to Emby. Both paths stay working.
- Server-composed home rows, rendered verbatim so new row types ship
  without an app release
- Full-screen animated maintenance state, row engagement telemetry

Gateway:
- One request per TV screen; auth, caching, search and row shaping
- Library import from Emby into Postgres (manual, then hourly incremental)
- Recommendations from viewing history (recency-weighted genre affinity)
- Admin page for imports, an offline switch, and per-row analytics
- Video always direct-plays from Emby; only metadata passes through

Identity is com.ponzischeme89.memby throughout, replacing
com.mattcohen.embyclientsname. A changed applicationId installs as a new
app: TVs need a fresh sign-in and the old package uninstalled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-07-27 08:16:20 +12:00
co-authored by Claude Opus 5
commit 2ce405c540
99 changed files with 14433 additions and 0 deletions
+32
View File
@@ -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://<host>: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=
+16
View File
@@ -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
+20
View File
@@ -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/
+224
View File
@@ -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:<userId>:rows` cache, and a miss triggers a deduplicated background
rebuild while home returns immediately. That key is intentionally outside the `u:`
namespace that mutations wipe; only a finished playback retires it.
**`EmbyRepository`** is the only place that talks to Emby. It keeps a `@Volatile` `snapshot`
of `Settings` collected from DataStore so synchronous callers (URL builders,
`rotationIntervalMillis`) don't suspend, and it caches the Retrofit `EmbyApi` instance,
rebuilding only when the base URL changes. All image and stream URLs are built here with
`api_key` appended. Errors reaching the UI go through `friendlyEmbyError` — never surface
raw HTTP bodies, which can contain tokens (the OkHttp logging interceptor is pinned at
`Level.NONE` for the same reason).
**Emby query conventions.** List endpoints request the narrowest `Fields` /
`EnableImageTypes` set that the row needs (`getHomeItems` enforces this); full metadata is
fetched only via `getItemDetails` after D-pad focus settles (140 ms debounce in
`HomeViewModel.focusItem`, with an LRU cache and cancellation of the in-flight job). Adding
fields to a home query is a startup-cost regression — extend the detail call instead.
Emby time values are 100-ns ticks; convert at the boundary (`millisecondsToTicks`,
`resumePositionMs`).
**Multi-profile session state.** `SettingsStore` stores a list of `EmbyProfile` (server,
token, userId, plus that profile's cached home JSON) *and* mirrors the active profile into
the flat top-level keys the rest of the app reads. `switchProfile`/`saveSession` must keep
both in sync; `legacyProfile()` synthesises a profile from the flat keys for installs that
predate the list. `deviceId` is intentionally preserved across `clearSession()`.
**Home startup path.** `HomeCache` (last successful home response) is persisted per profile
and used as the initial `HomeUiState`, so the launcher renders rows before the network
returns; sections then refresh in parallel under a `Mutex` and re-persist. Playback stops
are broadcast through `repository.playbackStops` and refresh only the Continue/Next-Up rows.
**Screensaver hosting.** `ScreensaverContent` is shared by `MembyDreamService` and
`ScreensaverActivity`. A `DreamService` is not a `ComponentActivity`, so
`DreamLifecycleOwner` supplies the ViewTree lifecycle/ViewModelStore/SavedState owners
Compose requires; D-pad handling lives in the composable while the hardware Play/Pause key
is intercepted in `dispatchKeyEvent` and routed via the `ScreensaverActions` holder.
Playback from the dream `finish()`es first and starts `PlayerActivity` on a delayed main-
thread post to avoid the "activity behind the dream" race.
**In-app updates.** `UpdateChecker` polls a user-configured **Gitea** release
(`/api/v1/repos/{owner}/{repo}/releases/latest`, token auth for private repos), downloads the
APK and hands it to the system installer via `FileProvider`. Because replacing the APK kills
a running Dream and leaves a black surface, `UpdateRecoveryReceiver` catches
`MY_PACKAGE_REPLACED` and relaunches `MainActivity` with
`EXTRA_LAUNCH_UPDATED_SLIDESHOW`.
**Playback** uses Emby's direct stream (`/Videos/{id}/stream?static=true`) — no
`PlaybackInfo`/transcode negotiation, so exotic codecs may fail. The
`media3-exoplayer-hls` dependency is already present for when that's added. Progress is
reported back to Emby via `reportPlaybackStarted/Progress/Stopped`.
**Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to
tag `EmbyClientPerf`; `benchmark/` is a `com.android.test` macrobenchmark module currently
targeting the debug build (`suppressErrors = DEBUGGABLE`), so its numbers are
debug-influenced.
## UI conventions
Use `androidx.tv.material3` components (`Button`, `Card`, `Text`) rather than the phone
Material 3 ones. `MainActivity.kt`, `HomeComponents.kt` and `ScreensaverContent.kt` are the
three large files — new screens generally belong in `ui/<feature>/` rather than growing
them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`);
everything must be reachable by D-pad only.
+144
View File
@@ -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\\<you>\\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.
```
+118
View File
@@ -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")
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

+8
View File
@@ -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 { *; }
+96
View File
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Needed to hand a downloaded APK to the system installer (in-app updates). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- This is a TV app: no touchscreen, uses the Leanback launcher. -->
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<application
android:name=".MembyApp"
android:allowBackup="true"
android:banner="@drawable/app_banner"
android:icon="@drawable/app_banner"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Memby">
<profileable android:shell="true" tools:targetApi="q" />
<!-- Home / setup screen. Registered on the TV (Leanback) launcher. -->
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|smallestScreenSize|screenLayout|orientation|uiMode">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- In-app preview of the screensaver (same UI the Daydream uses). -->
<activity
android:name=".ui.screensaver.ScreensaverActivity"
android:exported="false"
android:screenOrientation="landscape"
android:theme="@style/Theme.Memby.Fullscreen" />
<!-- An APK replacement kills an active Dream process. Reopen our launcher so
the TV is never left displaying the old, black Dream surface. -->
<receiver
android:name=".update.UpdateRecoveryReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- Fullscreen Media3 player. -->
<activity
android:name=".ui.player.PlayerActivity"
android:exported="false"
android:screenOrientation="landscape"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|screenSize|orientation"
android:theme="@style/Theme.Memby.Fullscreen" />
<!-- The system screensaver (Daydream / Ambient mode source).
Interactive: select to open the panel, play, or favourite. -->
<service
android:name=".screensaver.MembyDreamService"
android:exported="true"
android:icon="@drawable/app_banner"
android:label="@string/screensaver_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.service.dream"
android:resource="@xml/emby_dream" />
</service>
<!-- Serves the downloaded update APK to the system package installer. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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)
}
}
@@ -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)
}
}
@@ -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<HomeRow> = emptyList(),
val continueWatching: List<BaseItem> = emptyList(),
val nextUp: List<BaseItem> = emptyList(),
val favorites: List<BaseItem> = emptyList(),
val latestMovies: List<BaseItem> = 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<Settings> get() = settings.settingsFlow
private val _playbackStops = MutableSharedFlow<String>(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<BaseItem> {
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<BaseItem> {
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<BaseItem> {
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<BaseItem> {
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<BaseItem> {
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<BaseItem> =
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<HomeRow> = 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<String, String>,
fields: String,
imageTypes: String,
includeUserData: Boolean = false,
): List<BaseItem> {
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<GatewayRowEvent>) {
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<MaintenanceResponse>(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('/')
}
@@ -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
}
@@ -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<Preferences> 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<EmbyProfile> = 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<Settings> = 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<String>, 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<String>) {
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<HomeCache>(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<EmbyProfile> =
value?.let { runCatching { Json.decodeFromString<List<EmbyProfile>>(it) }.getOrNull() }.orEmpty()
private fun profilesFrom(preferences: Preferences): List<EmbyProfile> =
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<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
val nextUp: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
val favorites: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
val latestMovies: List<com.ponzischeme89.memby.data.model.BaseItem> = 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<com.ponzischeme89.memby.data.model.HomeRow> = emptyList(),
)
@@ -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<GatewayRowEvent>()
private val impressed = HashSet<String>()
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<GatewayRowEvent> = 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<SimpleDateFormat>() {
override fun initialValue(): SimpleDateFormat =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
timeZone = TimeZone.getTimeZone("UTC")
}
}
}
}
@@ -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<BaseItem> = 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<String> = emptyList(),
@SerialName("ProductionYear") val productionYear: Int? = null,
@SerialName("OfficialRating") val officialRating: String? = null,
@SerialName("CommunityRating") val communityRating: Double? = null,
@SerialName("Studios") val studios: List<Studio> = emptyList(),
@SerialName("RunTimeTicks") val runTimeTicks: Long? = null,
@SerialName("Genres") val genres: List<String> = emptyList(),
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
@SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
@SerialName("BackdropImageTags") val backdropImageTags: List<String> = emptyList(),
@SerialName("ImageTags") val imageTags: Map<String, String> = emptyMap(),
@SerialName("SeriesId") val seriesId: String? = null,
@SerialName("SeriesName") val seriesName: String? = null,
@SerialName("ParentBackdropItemId") val parentBackdropItemId: String? = null,
@SerialName("ParentBackdropImageTags") val parentBackdropImageTags: List<String> = 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 }
}
@@ -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<BaseItem> = emptyList(),
)
/** Everything the launcher renders, in one response. */
@Serializable
data class GatewayHome(
/** Server-composed rows, in display order. */
val rows: List<HomeRow> = emptyList(),
val continueWatching: List<BaseItem> = emptyList(),
val nextUp: List<BaseItem> = emptyList(),
val favorites: List<BaseItem> = emptyList(),
val latestMovies: List<BaseItem> = 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<HomeRow> = emptyList(),
)
@Serializable
data class GatewayItems(
val items: List<BaseItem> = 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<GatewayRowEvent>,
)
@Serializable
data class GatewayPlaybackReport(
val itemId: String,
val positionMs: Long,
val isPaused: Boolean = false,
)
@@ -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<String, String>,
): 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<String, String>): 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<String, String>,
): 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
}
@@ -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/"
@@ -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)
}
@@ -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())
}
}
@@ -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",
)
}
}
@@ -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()
}
}
File diff suppressed because it is too large Load Diff
@@ -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<BaseItem> = emptyList(),
val nextUp: List<BaseItem> = emptyList(),
val favorites: List<BaseItem> = emptyList(),
val latestMovies: List<BaseItem> = 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<HomeRow> = emptyList(),
val loading: Set<HomeSection> = 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<BaseItem>
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<HomeUiState> = _state.asStateFlow()
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
private var metadataJob: Job? = null
private val metadataCache = object : LinkedHashMap<String, BaseItem>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, BaseItem>?): 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<BaseItem>,
updateItems: (HomeUiState, List<BaseItem>) -> 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 <T : ViewModel> create(modelClass: Class<T>): T {
require(modelClass.isAssignableFrom(HomeViewModel::class.java))
return HomeViewModel(repository) as T
}
}
File diff suppressed because it is too large Load Diff
@@ -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,
)
}
}
@@ -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<TrackChoice>()
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
}
}
@@ -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
}
}
@@ -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…")
}
}
@@ -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<List<BaseItem>>(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<String?>(null) }
var toast by remember(startupMessage) { mutableStateOf(startupMessage) }
var reloadKey by remember { mutableIntStateOf(0) }
val favoriteOverrides = remember { mutableStateMapOf<String, Boolean>() }
val favoriteMutations = remember { mutableStateMapOf<String, Int>() }
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),
)
}
@@ -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<UpdateStatus?>(null) }
var installMessage by remember { mutableStateOf<String?>(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(),
)
}
}
}
@@ -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()
}
}
}
@@ -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<GiteaRelease>(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<Unit> =
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<Int> =
normalizeVersion(v).split('.', '-', ' ', '+').mapNotNull { it.toIntOrNull() }
private fun normalizeVersion(v: String): String = v.trim().trimStart('v', 'V')
}
@@ -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<GiteaAsset> = emptyList(),
)
@Serializable
data class GiteaAsset(
@SerialName("name") val name: String = "",
@SerialName("browser_download_url") val browserDownloadUrl: String = "",
)
@@ -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) }
}
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path
android:fillColor="#0B1220"
android:pathData="M0,0 h320 v180 h-320 z" />
<path
android:fillColor="#52B54B"
android:pathData="M130,60 L130,120 L180,90 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M40,140 h240 v6 h-240 z" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 849 KiB

+6
View File
@@ -0,0 +1,6 @@
<resources>
<string name="app_name">Memby</string>
<string name="screensaver_name">Memby Screensaver</string>
<string name="dream_description">Memby movie &amp; TV backdrops</string>
<string name="developer_name">ponzischeme89</string>
</resources>
+13
View File
@@ -0,0 +1,13 @@
<resources>
<style name="Theme.Memby" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
</style>
<style name="Theme.Memby.Fullscreen" parent="Theme.Memby">
<item name="android:windowFullscreen">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Screensaver metadata. "Customize" in the TV's screensaver settings opens the
app's MainActivity, where the server connection is configured. -->
<dream xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="com.ponzischeme89.memby/.ui.MainActivity"
android:previewImage="@drawable/app_banner" />
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Matches context.cacheDir, where the update APK is downloaded. -->
<cache-path name="apk_cache" path="." />
</paths>
@@ -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<GatewayHome>(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<GatewayHome>(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<GatewayHome>(
"""{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":false}""",
)
assertTrue(home.rows.isEmpty())
}
@Test
fun `a partial home payload still decodes`() {
val home = json.decodeFromString<GatewayHome>(
"""{"continueWatching":[],"nextUp":[],"favorites":[],"latestMovies":[],"partial":true}""",
)
assertTrue(home.partial)
}
@Test
fun `decodes a playback response`() {
val playback = json.decodeFromString<GatewayPlayback>(
"""{"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"))
}
}
@@ -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("<html>502 Bad Gateway</html>"))
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))
}
}
@@ -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))
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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, ""))
}
}
@@ -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)
}
}
@@ -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<String>(), mediaBadges(BaseItem(id = "unknown")))
}
}
@@ -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<HomeCache>(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<HomeCache>(legacy))
assertTrue(restored.rows.isEmpty())
assertEquals("a", restored.continueWatching.single().id)
}
}
@@ -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)
}
}
+44
View File
@@ -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")
}
+4
View File
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application android:requestLegacyExternalStorage="true" />
</manifest>
@@ -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()
}
}
+8
View File
@@ -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
}
+24
View File
@@ -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 }
+68
View File
@@ -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:
+21
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
Vendored
+252
View File
@@ -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" "$@"
Vendored
+94
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
.git
*.md
bin/
+21
View File
@@ -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"]
+246
View File
@@ -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://<host>: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 <token>` from `/v1/auth/login`. Image URLs
accept `?t=<token>` 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:<userId>: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://<host>: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:<embyUserId>:*`, plus session lookups under
`sess:<tokenHash>` and recommendation rows under `r:<embyUserId>: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.
+237
View File
@@ -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)
}
}
}
}
+19
View File
@@ -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
)
+38
View File
@@ -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=
+171
View File
@@ -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)
}
+288
View File
@@ -0,0 +1,288 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Memby admin</title>
<style>
:root {
color-scheme: dark;
--bg: #0b0e11; --panel: #151a20; --line: #232a32;
--text: #e6eaee; --muted: #97a1ab; --accent: #52b54b; --danger: #e5534b;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 28px; background: var(--bg); color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 980px; margin: 0 auto; display: grid; gap: 20px; }
h1 { font-size: 24px; margin: 0; }
h2 { font-size: 15px; margin: 0 0 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
header { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
section { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px; }
button {
background: var(--accent); color: #06240a; border: 0; border-radius: 7px;
padding: 9px 15px; font-weight: 600; font-size: 14px; cursor: pointer;
}
button.secondary { background: #2a323b; color: var(--text); }
button.danger { background: var(--danger); color: #fff; }
button:disabled { opacity: .45; cursor: not-allowed; }
input[type=text], input[type=password] {
background: #0e1216; border: 1px solid var(--line); border-radius: 7px;
color: var(--text); padding: 9px 11px; font-size: 14px; min-width: 260px;
}
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.stats { display: flex; gap: 26px; flex-wrap: wrap; margin-bottom: 14px; }
.stat b { display: block; font-size: 22px; font-weight: 600; }
.stat span { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
.scroll { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; font-size: 14px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.pill { padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.ok { background: #16351a; color: #7bd88f; }
.bad { background: #3a1d1c; color: #ff8a80; }
.warn { background: #3a3320; color: #f0c674; }
.muted { color: var(--muted); }
.banner { padding: 11px 14px; border-radius: 8px; background: #3a1d1c; color: #ffb3ad; display: none; }
.banner.show { display: block; }
</style>
</head>
<body>
<main>
<header>
<h1>Memby admin</h1>
<span id="live" class="pill muted">connecting…</span>
<span style="flex:1"></span>
<input type="password" id="token" placeholder="Admin token" autocomplete="off">
<button id="save-token" class="secondary">Save</button>
</header>
<div id="error" class="banner"></div>
<section>
<h2>Library</h2>
<div class="stats" id="library-stats"><span class="muted">Loading…</span></div>
<div class="row">
<button id="sync-incremental">Sync new items</button>
<button id="sync-full" class="secondary">Full re-import</button>
<span class="muted" id="sync-hint"></span>
</div>
</section>
<section>
<h2>Maintenance</h2>
<p class="muted" style="margin-top:0">
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.
</p>
<div class="row">
<input type="text" id="maintenance-message" placeholder="Message shown on the TV">
<button id="maintenance-on" class="danger">Go offline</button>
<button id="maintenance-off" class="secondary">Bring back online</button>
<span id="maintenance-state" class="pill"></span>
</div>
</section>
<section>
<h2>Row engagement</h2>
<div class="row" style="margin-bottom:12px">
<label class="muted" for="days">Window</label>
<select id="days" style="background:#0e1216;color:var(--text);border:1px solid var(--line);border-radius:7px;padding:8px">
<option value="1">24 hours</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
</select>
</div>
<div class="scroll">
<table>
<thead>
<tr>
<th>Row</th><th>Kind</th>
<th class="num">Dwell</th><th class="num">Impressions</th>
<th class="num">Focuses</th><th class="num">Opened</th>
<th class="num">Open rate</th><th class="num">Viewers</th>
</tr>
</thead>
<tbody id="analytics"><tr><td colspan="8" class="muted">No data yet.</td></tr></tbody>
</table>
</div>
</section>
<section>
<h2>Recent imports</h2>
<div class="scroll">
<table>
<thead>
<tr>
<th>Started</th><th>Kind</th><th>Trigger</th><th>Status</th>
<th class="num">Seen</th><th class="num">Written</th><th class="num">Removed</th><th>Notes</th>
</tr>
</thead>
<tbody id="runs"><tr><td colspan="8" class="muted">Nothing yet.</td></tr></tbody>
</table>
</div>
</section>
</main>
<script>
const tokenInput = document.getElementById('token');
tokenInput.value = localStorage.getItem('memby_admin_token') || '';
document.getElementById('save-token').addEventListener('click', () => {
localStorage.setItem('memby_admin_token', tokenInput.value.trim());
refresh();
});
function showError(message) {
const banner = document.getElementById('error');
banner.textContent = message || '';
banner.classList.toggle('show', Boolean(message));
}
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: {
'Authorization': 'Bearer ' + (localStorage.getItem('memby_admin_token') || ''),
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
if (response.status === 401) throw new Error('Invalid admin token.');
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || ('Request failed (' + response.status + ')'));
}
return response.status === 204 ? null : response.json();
}
const number = (value) => (value ?? 0).toLocaleString();
function duration(ms) {
if (!ms) return '0s';
const seconds = Math.round(ms / 1000);
if (seconds < 60) return seconds + 's';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ' + (seconds % 60) + 's';
return Math.floor(minutes / 60) + 'h ' + (minutes % 60) + 'm';
}
const when = (value) => (value ? new Date(value).toLocaleString() : '—');
function renderStatus(status) {
const byType = status.library.byType || {};
const types = Object.keys(byType).sort();
document.getElementById('library-stats').innerHTML =
'<div class="stat"><b>' + number(status.library.total) + '</b><span>items</span></div>' +
types.map((type) =>
'<div class="stat"><b>' + number(byType[type]) + '</b><span>' + type + '</span></div>').join('') +
'<div class="stat"><b style="font-size:15px">' + when(status.library.lastSynced) +
'</b><span>last import</span></div>';
const running = status.syncRunning;
document.getElementById('sync-incremental').disabled = running;
document.getElementById('sync-full').disabled = running;
document.getElementById('sync-hint').textContent = running
? 'Import running…'
: 'Automatic incremental import every ' + status.syncEvery + '.';
const maintenance = status.maintenance || {};
const state = document.getElementById('maintenance-state');
state.textContent = maintenance.enabled ? 'OFFLINE' : 'online';
state.className = 'pill ' + (maintenance.enabled ? 'bad' : 'ok');
const messageField = document.getElementById('maintenance-message');
if (document.activeElement !== messageField) messageField.value = maintenance.message || '';
document.getElementById('runs').innerHTML = (status.runs || []).length
? status.runs.map((run) => {
const pill = run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad';
return '<tr>' +
'<td>' + when(run.startedAt) + '</td>' +
'<td>' + run.kind + '</td>' +
'<td>' + run.trigger + '</td>' +
'<td><span class="pill ' + pill + '">' + run.status + '</span></td>' +
'<td class="num">' + number(run.itemsSeen) + '</td>' +
'<td class="num">' + number(run.itemsUpserted) + '</td>' +
'<td class="num">' + number(run.itemsRemoved) + '</td>' +
'<td class="muted">' + (run.error || '') + '</td>' +
'</tr>';
}).join('')
: '<tr><td colspan="8" class="muted">Nothing yet.</td></tr>';
}
function renderAnalytics(payload) {
const rows = payload.rows || [];
document.getElementById('analytics').innerHTML = rows.length
? rows.map((row) =>
'<tr>' +
'<td>' + row.rowId + '</td>' +
'<td class="muted">' + (row.rowKind || '—') + '</td>' +
'<td class="num">' + duration(row.dwellMs) + '</td>' +
'<td class="num">' + number(row.impressions) + '</td>' +
'<td class="num">' + number(row.focuses) + '</td>' +
'<td class="num">' + number(row.selects) + '</td>' +
'<td class="num">' + Math.round((row.selectRate || 0) * 100) + '%</td>' +
'<td class="num">' + number(row.viewers) + '</td>' +
'</tr>').join('')
: '<tr><td colspan="8" class="muted">No events in this window.</td></tr>';
}
async function refresh() {
const live = document.getElementById('live');
try {
const [status, analytics] = await Promise.all([
api('/admin/api/status'),
api('/admin/api/analytics?days=' + document.getElementById('days').value),
]);
renderStatus(status);
renderAnalytics(analytics);
live.textContent = 'updated ' + new Date().toLocaleTimeString();
live.className = 'pill ok';
showError('');
} catch (err) {
live.textContent = 'error';
live.className = 'pill bad';
showError(err.message);
}
}
async function act(fn) {
try {
await fn();
await refresh();
} catch (err) {
showError(err.message);
}
}
document.getElementById('sync-incremental').addEventListener('click', () =>
act(() => api('/admin/api/sync', { method: 'POST', body: JSON.stringify({ kind: 'incremental' }) })));
document.getElementById('sync-full').addEventListener('click', () => {
if (!confirm('Re-import the entire library? This can take several minutes.')) return;
act(() => api('/admin/api/sync', { method: 'POST', body: JSON.stringify({ kind: 'full' }) }));
});
document.getElementById('maintenance-on').addEventListener('click', () => {
if (!confirm('Take Memby offline for every TV?')) return;
act(() => api('/admin/api/maintenance', {
method: 'POST',
body: JSON.stringify({ enabled: true, message: document.getElementById('maintenance-message').value }),
}));
});
document.getElementById('maintenance-off').addEventListener('click', () =>
act(() => api('/admin/api/maintenance', {
method: 'POST',
body: JSON.stringify({ enabled: false, message: document.getElementById('maintenance-message').value }),
})));
document.getElementById('days').addEventListener('change', refresh);
refresh();
setInterval(refresh, 5000);
</script>
</body>
</html>
+196
View File
@@ -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)
}
})
}
+102
View File
@@ -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
}
+287
View File
@@ -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
}
+143
View File
@@ -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)
}
}
+101
View File
@@ -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,
})
}
+49
View File
@@ -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)
}
+285
View File
@@ -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) }
+65
View File
@@ -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)
}
}
+104
View File
@@ -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)
}
+87
View File
@@ -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,
})
})
}
+159
View File
@@ -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
}
+132
View File
@@ -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
}
+97
View File
@@ -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 }
+136
View File
@@ -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
}
+325
View File
@@ -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
}
+342
View File
@@ -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, " ")
}
+79
View File
@@ -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)
}
}
+270
View File
@@ -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
}
+230
View File
@@ -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
}
+258
View File
@@ -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
}
+183
View File
@@ -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)
}
}
+113
View File
@@ -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
}
+181
View File
@@ -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()
}
+88
View File
@@ -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);
+80
View File
@@ -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
}
+113
View File
@@ -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
}
+101
View File
@@ -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
}
+27
View File
@@ -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")