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