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:
@@ -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=
|
||||
@@ -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
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
```
|
||||
@@ -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")
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 849 KiB |
Vendored
+8
@@ -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 { *; }
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,6 @@
|
||||
<resources>
|
||||
<string name="app_name">Memby</string>
|
||||
<string name="screensaver_name">Memby Screensaver</string>
|
||||
<string name="dream_description">Memby movie & TV backdrops</string>
|
||||
<string name="developer_name">ponzischeme89</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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:
|
||||
@@ -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
|
||||
Vendored
BIN
Binary file not shown.
+7
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
*.md
|
||||
bin/
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+97
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user