0.2.73
This commit is contained in:
+2
-3
@@ -32,11 +32,10 @@ local.properties
|
|||||||
*.secret
|
*.secret
|
||||||
/secrets/
|
/secrets/
|
||||||
|
|
||||||
# Local environment variants may contain host paths or release settings. The two
|
# Local environment variants may contain host paths or release settings. The
|
||||||
# checked-in examples contain names/placeholders only.
|
# checked-in example contains names/placeholders only.
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!builder.env.example
|
|
||||||
|
|
||||||
# The installed APK a deploy script pulls off a television to compare against.
|
# The installed APK a deploy script pulls off a television to compare against.
|
||||||
/.tmp-memby-installed-base.apk
|
/.tmp-memby-installed-base.apk
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ docker compose up -d --build # from the repo root; needs .env (see .env.e
|
|||||||
```powershell
|
```powershell
|
||||||
.\deploy-server.ps1 # local tree -> 10.0.0.213:/share/Docker/Memby
|
.\deploy-server.ps1 # local tree -> 10.0.0.213:/share/Docker/Memby
|
||||||
.\deploy-server.ps1 -SourceDirectory C:\src\memby -Destination /share/Docker/Memby-test
|
.\deploy-server.ps1 -SourceDirectory C:\src\memby -Destination /share/Docker/Memby-test
|
||||||
.\deploy-server.ps1 -SkipAppRelease -SkipBuilder # server/admin-only; reuse Android builder image
|
.\deploy-server.ps1 -SkipAppRelease # server/admin-only; no APK is built
|
||||||
```
|
```
|
||||||
|
|
||||||
It tars the local `server/`, `docker-compose.yml` and `.env.example`, and streams them over
|
It tars the local `server/`, `docker-compose.yml` and `.env.example`, and streams them over
|
||||||
@@ -88,9 +88,9 @@ one SSH connection (interactive password; stdin carries the
|
|||||||
archive, so OpenSSH prompts on the tty). The remote half stages into
|
archive, so OpenSSH prompts on the tty). The remote half stages into
|
||||||
`<destination>.new.$$`, builds, then swaps directories and waits for all three health
|
`<destination>.new.$$`, builds, then swaps directories and waits for all three health
|
||||||
checks, restoring the previous release if anything fails. The named Postgres volume is
|
checks, restoring the previous release if anything fails. The named Postgres volume is
|
||||||
preserved — it never runs `compose down -v`. For a server/admin-only change, combine
|
preserved — it never runs `compose down -v`. For a server/admin-only change, pass
|
||||||
`-SkipAppRelease -SkipBuilder`: this skips the local APK build and preserves the running
|
`-SkipAppRelease`: this skips the local APK build so only the gateway and admin console are
|
||||||
Android builder container and image while only the gateway and admin console are replaced.
|
replaced.
|
||||||
|
|
||||||
**`.env.example` is the configuration.** It holds real values, and every deployment
|
**`.env.example` is the configuration.** It holds real values, and every deployment
|
||||||
overwrites the NAS's `.env` with the local copy (the old one is kept beside it as
|
overwrites the NAS's `.env` with the local copy (the old one is kept beside it as
|
||||||
@@ -129,15 +129,12 @@ patch` (0.1.53 → 153). Bump both together — the in-app updater compares `ver
|
|||||||
while Android refuses an APK whose `versionCode` went backwards. `release.ps1 -Version`
|
while Android refuses an APK whose `versionCode` went backwards. `release.ps1 -Version`
|
||||||
rewrites both, so prefer it over editing the build file by hand.
|
rewrites both, so prefer it over editing the build file by hand.
|
||||||
|
|
||||||
**Releases.** APKs are self-hosted (NAS or any web server), not on a store. The preferred
|
**Releases.** APKs are self-hosted (NAS or any web server), not on a store. An APK is built
|
||||||
The normal NAS path is Admin Console → App updates → **Build latest release**. The isolated
|
on a workstation or by CI and published to the gateway through `POST /admin/api/release`,
|
||||||
service under `builder/` fetches the highest semantic GitHub tag, builds that exact source,
|
which stores it in the shared `memby-releases` volume — either by `deploy-server.ps1` (which
|
||||||
verifies the APK and publishes it through the gateway into the shared `memby-releases`
|
builds, signs, verifies and publishes in the same operation) or by
|
||||||
volume. An exact tag may be selected as a deliberate fallback, and
|
`.gitea/workflows/release.yml` on a pushed semantic tag. The gateway itself builds nothing;
|
||||||
`docker compose run --rm --build memby-builder release` remains the controller-independent
|
its release-publish token is a read-only Compose secret under `/run/secrets`.
|
||||||
command-line fallback. Its keystore, signing credentials and release-publish token are
|
|
||||||
read-only Compose secrets under `/run/secrets`; the Gradle cache is a named volume. See
|
|
||||||
`builder/README.md`.
|
|
||||||
|
|
||||||
The existing `release.ps1` compatibility path builds a signed APK and assembles
|
The existing `release.ps1` compatibility path builds a signed APK and assembles
|
||||||
`dist/out/` — `index.html` (landing page from `dist/template/`), `latest.json` (the manifest
|
`dist/out/` — `index.html` (landing page from `dist/template/`), `latest.json` (the manifest
|
||||||
|
|||||||
@@ -1,11 +1,68 @@
|
|||||||
# Memby (Android TV)
|
<div align="center">
|
||||||
|
|
||||||
An independent Android TV client for Emby, by **ponzischeme89**. Memby combines a
|
<img src="memby-icon.png" alt="Memby" width="128" height="128">
|
||||||
personalised television launcher, full media player, system screensaver and an optional
|
|
||||||
self-hosted gateway that shapes the experience for every viewer using your library.
|
# Memby
|
||||||
|
|
||||||
|
**An independent Android TV client for Emby.**
|
||||||
|
|
||||||
|
A personalised television launcher, a full media player, a system screensaver and an
|
||||||
|
optional self-hosted gateway that shapes the experience for every viewer in the house.
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
[](#build--install)
|
||||||
|
[](#build--install)
|
||||||
|
[](#tech-stack)
|
||||||
|
[](server/README.md)
|
||||||
|
|
||||||
Source: [g.sublogue.com/admin/memby](https://g.sublogue.com/admin/memby)
|
Source: [g.sublogue.com/admin/memby](https://g.sublogue.com/admin/memby)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [How it works](#how-it-works)
|
||||||
|
- [Features](#features)
|
||||||
|
- [Browse and discover](#browse-and-discover)
|
||||||
|
- [Playback](#playback)
|
||||||
|
- [Profiles, televisions and administration](#profiles-televisions-and-administration)
|
||||||
|
- [Screensaver and updates](#screensaver-and-updates)
|
||||||
|
- [Tech stack](#tech-stack)
|
||||||
|
- [Build & install](#build--install)
|
||||||
|
- [Server address](#server-address)
|
||||||
|
- [First run](#first-run)
|
||||||
|
- [Running the gateway](#running-the-gateway)
|
||||||
|
- [Project layout](#project-layout)
|
||||||
|
- [Distributing builds](#distributing-builds)
|
||||||
|
- [One-time: a signing key](#one-time-a-signing-key)
|
||||||
|
- [NAS deployment release (recommended)](#nas-deployment-release-recommended)
|
||||||
|
- [Local PowerShell release (compatibility path)](#local-powershell-release-compatibility-path)
|
||||||
|
- [How TVs update themselves](#how-tvs-update-themselves)
|
||||||
|
- [Upgrading to v0.1.53](#upgrading-to-v0153)
|
||||||
|
- [Notes & limitations](#notes--limitations)
|
||||||
|
- [Licence](#licence)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
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 stream; never proxied by the gateway)
|
||||||
|
```
|
||||||
|
|
||||||
|
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 the app talks to Emby directly. Both paths are
|
||||||
|
maintained — the direct path is the fallback when the container is down.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Browse and discover
|
### Browse and discover
|
||||||
@@ -59,12 +116,79 @@ Source: [g.sublogue.com/admin/memby](https://g.sublogue.com/admin/memby)
|
|||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- Kotlin + Jetpack **Compose for TV** (`androidx.tv:tv-material3`)
|
| Layer | Built with |
|
||||||
- **Media3 / ExoPlayer** for playback
|
| --- | --- |
|
||||||
- Retrofit + OkHttp + kotlinx.serialization for the Emby REST API
|
| UI | Kotlin + Jetpack **Compose for TV** (`androidx.tv:tv-material3`) |
|
||||||
- DataStore for persisted profiles, settings and cached launcher state
|
| Playback | **Media3 / ExoPlayer** |
|
||||||
- Coil for backdrop image loading
|
| Networking | Retrofit + OkHttp + kotlinx.serialization, over one shared HTTP stack |
|
||||||
- Go gateway with Postgres and Redis
|
| Storage | DataStore for profiles, settings and cached launcher state |
|
||||||
|
| Images | Coil |
|
||||||
|
| Gateway | Go, with Postgres and Redis |
|
||||||
|
|
||||||
|
## 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 test # JVM unit tests
|
||||||
|
.\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`.
|
||||||
|
|
||||||
|
## 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".
|
||||||
|
|
||||||
|
## Running the gateway
|
||||||
|
|
||||||
|
See [`server/README.md`](server/README.md) to run the container. In short, the gateway
|
||||||
|
imports Emby's catalogue into Postgres (once manually, then hourly for new episodes),
|
||||||
|
composes the home rows — including "Recommended from your watching history" — and serves an
|
||||||
|
admin console at `/admin/` for imports, an offline switch, and per-row engagement.
|
||||||
|
|
||||||
|
For Tracearr-powered recommendations, create a read-only public API key in Tracearr and set
|
||||||
|
`MEMBY_TRACEARR_URL` plus `MEMBY_TRACEARR_API_KEY` on the gateway (and optionally
|
||||||
|
`MEMBY_TRACEARR_SERVER_ID` when Tracearr monitors several servers). Tracearr credentials
|
||||||
|
remain server-side; the Android app only receives ranked Emby items and short reasons. The
|
||||||
|
gateway imports compact session signals into Postgres every five minutes and prepares an
|
||||||
|
over-provisioned per-user pool, so opening For You normally performs one indexed database
|
||||||
|
read. The live on-demand path remains available for cold starts and failed rebuilds.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
@@ -90,98 +214,21 @@ app/src/main/java/com/ponzischeme89/memby/
|
|||||||
player/PlayerActivity.kt Media3 playback
|
player/PlayerActivity.kt Media3 playback
|
||||||
screensaver/MembyDreamService.kt System screensaver (hosts ScreensaverContent)
|
screensaver/MembyDreamService.kt System screensaver (hosts ScreensaverContent)
|
||||||
|
|
||||||
|
admin-ui/ The operations console (React, served by nginx)
|
||||||
server/ The Memby gateway (Go) — see server/README.md
|
server/ The Memby gateway (Go) — see server/README.md
|
||||||
|
benchmark/ Macrobenchmarks and baseline-profile generation
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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 stream; never proxied by the gateway)
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
For Tracearr-powered recommendations, create a read-only public API key in Tracearr and
|
|
||||||
set `MEMBY_TRACEARR_URL` plus `MEMBY_TRACEARR_API_KEY` on the gateway (and optionally
|
|
||||||
`MEMBY_TRACEARR_SERVER_ID` when Tracearr monitors several servers). Tracearr credentials
|
|
||||||
remain server-side; the Android app only receives ranked Emby items and short reasons.
|
|
||||||
The gateway imports compact session signals into Postgres every five minutes and prepares
|
|
||||||
an over-provisioned per-user pool, so opening For You normally performs one indexed
|
|
||||||
database read. The live on-demand path remains available for cold starts and failed
|
|
||||||
rebuilds.
|
|
||||||
|
|
||||||
## 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".
|
|
||||||
|
|
||||||
## Distributing builds
|
## Distributing builds
|
||||||
|
|
||||||
Memby is handed out as an APK from your own web server or NAS, and updates itself from
|
Memby is handed out as an APK from your own web server or NAS, and updates itself from the
|
||||||
the same folder.
|
same folder.
|
||||||
|
|
||||||
### One-time: a signing key
|
### One-time: a signing key
|
||||||
|
|
||||||
Android identifies an app by `applicationId` **plus signing key**. Every update must be
|
Android identifies an app by `applicationId` **plus signing key**. Every update must be
|
||||||
signed with the *same* key, or the TV rejects it as a different app. Lose the key and
|
signed with the *same* key, or the TV rejects it as a different app. Lose the key and every
|
||||||
every user has to uninstall and reinstall.
|
user has to uninstall and reinstall.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
keytool -genkeypair -v -keystore memby-release.jks -alias memby `
|
keytool -genkeypair -v -keystore memby-release.jks -alias memby `
|
||||||
@@ -198,30 +245,23 @@ memby.keyAlias=memby
|
|||||||
memby.keyPassword=…
|
memby.keyPassword=…
|
||||||
```
|
```
|
||||||
|
|
||||||
Without these, `assembleRelease` still builds but the APK is unsigned and will not
|
Without these, `assembleRelease` still builds but the APK is unsigned and will not install.
|
||||||
install. The build prints a warning saying so.
|
The build prints a warning saying so.
|
||||||
|
|
||||||
### Docker release builder (recommended)
|
### NAS deployment release (recommended)
|
||||||
|
|
||||||
The backend Compose project includes an isolated `memby-builder` service. It keeps the
|
`deploy-server.ps1` builds and publishes the TV app in the same operation that replaces the
|
||||||
Android SDK out of the gateway image, fetches the newest semantic GitHub tag, builds with
|
gateway: it signs with the existing keystore, verifies the APK and publishes it through
|
||||||
the tagged source's Gradle wrapper, signs with the existing externally mounted keystore,
|
`POST /admin/api/release` once the new gateway is healthy. A pushed semantic tag does the
|
||||||
verifies the APK and publishes it into the gateway's existing release volume and update
|
same thing through `.gitea/workflows/release.yml`. The gateway builds nothing itself.
|
||||||
policy. Admin Console → App updates provides a **Build latest release** button, optional
|
|
||||||
exact-tag fallback, required-update confirmation, live status and build output.
|
|
||||||
|
|
||||||
After the one-time secret-file setup in [`builder/README.md`](builder/README.md), use the
|
|
||||||
Admin Console button. The Linux/NAS command-line fallback remains:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose run --rm --build memby-builder release
|
|
||||||
```
|
|
||||||
|
|
||||||
The signed APK and its checksum are stored in the `memby-releases` volume at
|
The signed APK and its checksum are stored in the `memby-releases` volume at
|
||||||
`/data/releases/memby-<version>.apk` and
|
`/data/releases/memby-<version>.apk` and `/data/releases/memby-<version>.apk.sha256`. The
|
||||||
`/data/releases/memby-<version>.apk.sha256`. Gradle downloads are retained in the
|
release-publish credential is a read-only Compose secret under `/run/secrets` and is never
|
||||||
`memby-gradle-cache` volume. The keystore and credentials remain read-only Compose secrets
|
copied into the image or repository.
|
||||||
under `/run/secrets` and are never copied into the image or repository.
|
|
||||||
|
Admin Console → App updates sets the update policy — latest version, APK URL, release
|
||||||
|
notes, and the required/destructive toggles.
|
||||||
|
|
||||||
### Local PowerShell release (compatibility path)
|
### Local PowerShell release (compatibility path)
|
||||||
|
|
||||||
@@ -231,17 +271,16 @@ under `/run/secrets` and are never copied into the image or repository.
|
|||||||
-SourceUrl https://g.sublogue.com/admin/memby
|
-SourceUrl https://g.sublogue.com/admin/memby
|
||||||
```
|
```
|
||||||
|
|
||||||
That existing workflow still bumps `versionName`/`versionCode`, runs the tests, builds a
|
That workflow bumps `versionName`/`versionCode`, runs the tests, builds a signed APK, and
|
||||||
signed APK, and fills
|
fills `dist/out/` with:
|
||||||
`dist/out/` with:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
index.html the page people are sent to
|
index.html the page people are sent to
|
||||||
latest.json the update manifest the app polls
|
latest.json the update manifest the app polls
|
||||||
memby-0.1.54.apk the build
|
memby-0.1.54.apk the build
|
||||||
memby-0.1.54.apk.sha256 the SHA-256 checksum
|
memby-0.1.54.apk.sha256 the SHA-256 checksum
|
||||||
LICENSE GNU GPL v2 terms
|
LICENSE GNU GPL v2 terms
|
||||||
NOTICE copyright and third-party acknowledgements
|
NOTICE copyright and third-party acknowledgements
|
||||||
```
|
```
|
||||||
|
|
||||||
Copy those to the folder the NAS serves. Old APKs can stay alongside — only `latest.json`
|
Copy those to the folder the NAS serves. Old APKs can stay alongside — only `latest.json`
|
||||||
@@ -252,8 +291,8 @@ decides what the app offers, so rolling back is editing one file.
|
|||||||
The gateway checks the running app version on launch and returns an optional or mandatory
|
The gateway checks the running app version on launch and returns an optional or mandatory
|
||||||
update with its download address. Memby downloads the APK, verifies its checksum, package
|
update with its download address. Memby downloads the APK, verifies its checksum, package
|
||||||
and release signature, then commits it through Android's package installer. Update policy
|
and release signature, then commits it through Android's package installer. Update policy
|
||||||
and the download address are managed in the gateway's admin console; viewers do not need
|
and the download address are managed in the gateway's admin console; viewers do not need to
|
||||||
to configure an update source on the television.
|
configure an update source on the television.
|
||||||
|
|
||||||
First install on each TV still has to be manual — the **Downloader** app pointed at the
|
First install on each TV still has to be manual — the **Downloader** app pointed at the
|
||||||
landing page is the usual route, and the page explains it.
|
landing page is the usual route, and the page explains it.
|
||||||
@@ -267,31 +306,31 @@ different app, so on every TV:
|
|||||||
1. Install v0.1.53 — it appears as a **second** Memby entry in the launcher.
|
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.
|
2. Sign in again; the previous session does not carry over.
|
||||||
3. Uninstall the old app: `adb uninstall com.mattcohen.embyclientsname`.
|
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
|
4. Re-select Memby in the TV's Screensaver settings — the Dream's component name changed as
|
||||||
as well, so the old selection no longer resolves.
|
well, so the old selection no longer resolves.
|
||||||
|
|
||||||
## Notes & limitations
|
## Notes & limitations
|
||||||
|
|
||||||
- Playback negotiates through Emby's `PlaybackInfo` endpoint, then streams directly from
|
- Playback negotiates through Emby's `PlaybackInfo` endpoint, then streams directly from
|
||||||
Emby. The Wholphin/Jellyfin-derived capability engine reports Android's H.264 and HEVC
|
Emby. The Wholphin/Jellyfin-derived capability engine reports Android's H.264 and HEVC
|
||||||
profiles, maximum levels and resolutions rather than assuming every decoder handles every
|
profiles, maximum levels and resolutions rather than assuming every decoder handles every
|
||||||
file. The Moonfin-derived audio path reports formats the TV can decode or bitstream, offers
|
file. The Moonfin-derived audio path reports formats the TV can decode or bitstream,
|
||||||
automatic receiver detection and manual per-codec passthrough overrides, and decodes the
|
offers automatic receiver detection and manual per-codec passthrough overrides, and
|
||||||
rest to PCM through Media3's FFmpeg renderer. Emby can preserve a supported video stream
|
decodes the rest to PCM through Media3's FFmpeg renderer. Emby can preserve a supported
|
||||||
while converting only incompatible audio or subtitles. If a vendor decoder still fails,
|
video stream while converting only incompatible audio or subtitles. If a vendor decoder
|
||||||
Media3 tries another decoder and Memby ultimately requests an H.264 HLS transcode instead
|
still fails, Media3 tries another decoder and Memby ultimately requests an H.264 HLS
|
||||||
of abandoning playback.
|
transcode instead of abandoning playback.
|
||||||
- Cleartext HTTP is enabled so local `http://` servers work out of the box. For an HTTPS-only
|
- Cleartext HTTP is enabled so local `http://` servers work out of the box. For an
|
||||||
server this is unnecessary but harmless.
|
HTTPS-only server this is unnecessary but harmless.
|
||||||
- The device is remembered across sign-outs (stable `DeviceId`); credentials are cleared.
|
- The device is remembered across sign-outs (stable `DeviceId`); credentials are cleared.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
Memby is free software licensed under the [GNU General Public License v2](LICENSE).
|
Memby is free software licensed under the [GNU General Public License v2](LICENSE).
|
||||||
Copyright and third-party acknowledgements are recorded in [NOTICE](NOTICE). Distributed
|
Copyright and third-party acknowledgements are recorded in [NOTICE](NOTICE). Distributed
|
||||||
APKs and server binaries must be accompanied by the corresponding source in accordance
|
APKs and server binaries must be accompanied by the corresponding source in accordance with
|
||||||
with GPLv2. The Android TV app also exposes the source link, notices, and complete licence
|
GPLv2. The Android TV app also exposes the source link, notices, and complete licence under
|
||||||
under **Settings → About / Licences**.
|
**Settings → About / Licences**.
|
||||||
|
|
||||||
Playback capability probing and device-profile generation contain GPLv2 adaptations from
|
Playback capability probing and device-profile generation contain GPLv2 adaptations from
|
||||||
[Wholphin](https://github.com/damontecres/Wholphin), itself derived in part from
|
[Wholphin](https://github.com/damontecres/Wholphin), itself derived in part from
|
||||||
|
|||||||
@@ -114,17 +114,6 @@ export interface HeroPolicy {
|
|||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReleaseBuilderStatus {
|
|
||||||
state: 'idle' | 'running' | 'succeeded' | 'failed';
|
|
||||||
tag?: string;
|
|
||||||
mandatory: boolean;
|
|
||||||
startedAt?: string;
|
|
||||||
finishedAt?: string;
|
|
||||||
message?: string;
|
|
||||||
logs: string[] | null;
|
|
||||||
fallback: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HeroPlacement = 'home' | 'movies' | 'tv_shows';
|
export type HeroPlacement = 'home' | 'movies' | 'tv_shows';
|
||||||
|
|
||||||
export interface HeroPlacementPolicy {
|
export interface HeroPlacementPolicy {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { ReleaseBuilderStatus } from '../api/types';
|
|
||||||
import { useAction } from '../lib/hooks';
|
import { useAction } from '../lib/hooks';
|
||||||
import { useGateway } from '../lib/gateway';
|
import { useGateway } from '../lib/gateway';
|
||||||
import { useToast } from '../lib/toast';
|
import { useToast } from '../lib/toast';
|
||||||
import { Banner, Button, Card, Confirm, Field, Loading, Note, PageHead, Tag, Toggle } from '../components/ui';
|
import { Banner, Button, Card, Confirm, Field, Loading, PageHead, Tag, Toggle } from '../components/ui';
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -21,13 +20,6 @@ export function UpdatesPage() {
|
|||||||
const { busy, run } = useAction();
|
const { busy, run } = useAction();
|
||||||
const [draft, setDraft] = useState<Draft | null>(null);
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
const [confirming, setConfirming] = useState(false);
|
const [confirming, setConfirming] = useState(false);
|
||||||
const [releaseConfirming, setReleaseConfirming] = useState(false);
|
|
||||||
const [releaseTag, setReleaseTag] = useState('');
|
|
||||||
const [releaseNotes, setReleaseNotes] = useState('');
|
|
||||||
const [releaseMandatory, setReleaseMandatory] = useState(false);
|
|
||||||
const [builder, setBuilder] = useState<ReleaseBuilderStatus | null>(null);
|
|
||||||
const [builderError, setBuilderError] = useState('');
|
|
||||||
const completedAt = useRef('');
|
|
||||||
|
|
||||||
const policy = status?.updatePolicy;
|
const policy = status?.updatePolicy;
|
||||||
|
|
||||||
@@ -47,32 +39,6 @@ export function UpdatesPage() {
|
|||||||
});
|
});
|
||||||
}, [policy, draft]);
|
}, [policy, draft]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
const poll = async () => {
|
|
||||||
try {
|
|
||||||
const next = await api.get<ReleaseBuilderStatus>('/admin/api/release-builder');
|
|
||||||
if (!active) return;
|
|
||||||
setBuilder(next);
|
|
||||||
setBuilderError('');
|
|
||||||
if (next.state === 'succeeded' && next.finishedAt && completedAt.current !== next.finishedAt) {
|
|
||||||
completedAt.current = next.finishedAt;
|
|
||||||
setDraft(null);
|
|
||||||
await reload();
|
|
||||||
}
|
|
||||||
} catch (cause) {
|
|
||||||
if (!active) return;
|
|
||||||
setBuilderError(cause instanceof Error ? cause.message : 'The release builder is unavailable.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void poll();
|
|
||||||
const timer = window.setInterval(() => void poll(), 3000);
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, [reload]);
|
|
||||||
|
|
||||||
const save = (enabled: boolean) =>
|
const save = (enabled: boolean) =>
|
||||||
run(enabled ? 'save' : 'off', async () => {
|
run(enabled ? 'save' : 'off', async () => {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
@@ -100,92 +66,10 @@ export function UpdatesPage() {
|
|||||||
|
|
||||||
const patch = (next: Partial<Draft>) => setDraft((current) => (current ? { ...current, ...next } : current));
|
const patch = (next: Partial<Draft>) => setDraft((current) => (current ? { ...current, ...next } : current));
|
||||||
|
|
||||||
const startRelease = () =>
|
|
||||||
run('release', async () => {
|
|
||||||
const next = await wrap(
|
|
||||||
() =>
|
|
||||||
api.post<ReleaseBuilderStatus>('/admin/api/release-builder', {
|
|
||||||
tag: releaseTag.trim(),
|
|
||||||
notes: releaseNotes.trim(),
|
|
||||||
mandatory: releaseMandatory,
|
|
||||||
}),
|
|
||||||
'Memby release started.',
|
|
||||||
);
|
|
||||||
if (next) {
|
|
||||||
setBuilder(next);
|
|
||||||
setBuilderError('');
|
|
||||||
setReleaseConfirming(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const builderTone =
|
|
||||||
builder?.state === 'succeeded'
|
|
||||||
? 'ok'
|
|
||||||
: builder?.state === 'failed'
|
|
||||||
? 'bad'
|
|
||||||
: builder?.state === 'running'
|
|
||||||
? 'warn'
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead title="App updates" intro="Publish an optional or a required client update." />
|
<PageHead title="App updates" intro="Publish an optional or a required client update." />
|
||||||
<Banner message={error} />
|
<Banner message={error} />
|
||||||
<Banner message={builderError} />
|
|
||||||
|
|
||||||
<Card
|
|
||||||
title="Build and publish"
|
|
||||||
intro="Build the latest tagged Android app in Docker, sign it with Memby's existing certificate, verify it, and publish it to televisions. The builder runs separately from the gateway."
|
|
||||||
icon="download"
|
|
||||||
tone="data"
|
|
||||||
actions={<Tag tone={builderTone}>{builder?.state ?? 'checking'}</Tag>}
|
|
||||||
footer={
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
busy={busy === 'release' || builder?.state === 'running'}
|
|
||||||
disabled={Boolean(builderError)}
|
|
||||||
onClick={() => (releaseMandatory ? setReleaseConfirming(true) : void startRelease())}
|
|
||||||
>
|
|
||||||
{releaseTag.trim() ? `Build ${releaseTag.trim()}` : 'Build latest release'}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="fields">
|
|
||||||
<Field
|
|
||||||
label="GitHub tag override"
|
|
||||||
hint="Leave blank for the latest semantic tag. An exact tag bypasses discovery or repeats that tag; the gateway still refuses downgrades."
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={releaseTag}
|
|
||||||
disabled={builder?.state === 'running'}
|
|
||||||
placeholder="v0.2.64 (blank uses latest)"
|
|
||||||
onChange={(event) => setReleaseTag(event.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field label="Release notes" hint="Leave blank to use this version's CHANGELOG entry.">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={releaseNotes}
|
|
||||||
disabled={builder?.state === 'running'}
|
|
||||||
placeholder="What's new on the television"
|
|
||||||
onChange={(event) => setReleaseNotes(event.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
|
||||||
label="Make this update required"
|
|
||||||
hint="Older televisions cannot dismiss the update prompt. You will confirm before the build starts."
|
|
||||||
checked={releaseMandatory}
|
|
||||||
disabled={builder?.state === 'running'}
|
|
||||||
onChange={setReleaseMandatory}
|
|
||||||
/>
|
|
||||||
{builder?.message ? <Note tone={builderTone}>{builder.message}</Note> : null}
|
|
||||||
{builder?.logs?.length ? <pre className="code">{builder.logs.join('\n')}</pre> : null}
|
|
||||||
<Note>
|
|
||||||
Command-line fallback: <code>{builder?.fallback ?? 'docker compose run --rm --build memby-builder release'}</code>
|
|
||||||
</Note>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{loading || !draft ? (
|
{loading || !draft ? (
|
||||||
<Loading rows={1} />
|
<Loading rows={1} />
|
||||||
@@ -303,17 +187,6 @@ export function UpdatesPage() {
|
|||||||
onCancel={() => setConfirming(false)}
|
onCancel={() => setConfirming(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{releaseConfirming ? (
|
|
||||||
<Confirm
|
|
||||||
title="Build a required update?"
|
|
||||||
body="When this signed APK is published, every older television will be blocked until it installs the update."
|
|
||||||
confirmLabel="Build and publish"
|
|
||||||
busy={busy === 'release'}
|
|
||||||
onConfirm={() => void startRelease()}
|
|
||||||
onCancel={() => setReleaseConfirming(false)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ val projectNoticeText =
|
|||||||
|
|
||||||
// A release workflow can derive the app version from its Git tag without editing the
|
// A release workflow can derive the app version from its Git tag without editing the
|
||||||
// source tree. Local builds keep using the checked-in default.
|
// source tree. Local builds keep using the checked-in default.
|
||||||
val defaultVersionName = "0.2.72"
|
val defaultVersionName = "0.2.73"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -869,30 +869,47 @@ class EmbyRepository internal constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One stable, paged slice of all films or all series.
|
* One stable, paged slice of all films, all series, or both.
|
||||||
*
|
*
|
||||||
* This deliberately mirrors [browseGenre] so switching between All and a genre never
|
* This deliberately mirrors [browseGenre] so switching between All and a genre never
|
||||||
* changes the grid's ordering, user-data fields, or infinite-scroll behaviour.
|
* changes the grid's ordering, user-data fields, or infinite-scroll behaviour — down to
|
||||||
|
* how the two read [itemType], which is why the resolution below is the same rule.
|
||||||
|
* The Genres destination is the mixed case: its "All genres" entry is the catalogue
|
||||||
|
* itself, where the Movies and TV Series pages name a type and never cross media types.
|
||||||
*/
|
*/
|
||||||
suspend fun browseLibrary(
|
suspend fun browseLibrary(
|
||||||
offset: Int = 0,
|
offset: Int = 0,
|
||||||
limit: Int = GENRE_PAGE_SIZE,
|
limit: Int = GENRE_PAGE_SIZE,
|
||||||
itemType: String,
|
itemType: String?,
|
||||||
): GenrePage {
|
): GenrePage {
|
||||||
val embyItemType = if (itemType.trim().equals("Series", ignoreCase = true)) "Series" else "Movie"
|
val embyItemType = when (itemType?.trim()?.lowercase()) {
|
||||||
|
"movie" -> "Movie"
|
||||||
|
"series" -> "Series"
|
||||||
|
else -> "Movie,Series"
|
||||||
|
}
|
||||||
if (ServerConfig.isGateway) {
|
if (ServerConfig.isGateway) {
|
||||||
runCatching { requireGateway().libraryItems(offset, limit, embyItemType) }
|
runCatching {
|
||||||
|
// The mixed shelf is the route's own default, so it is asked for by saying
|
||||||
|
// nothing rather than by naming both types.
|
||||||
|
requireGateway().libraryItems(
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
embyItemType.takeUnless { it == "Movie,Series" }.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
.onSuccess { page -> return GenrePage(page.items, offset, page.total) }
|
.onSuccess { page -> return GenrePage(page.items, offset, page.total) }
|
||||||
.onFailure { error ->
|
.onFailure { error ->
|
||||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||||
if (offset > 0) throw error
|
if (offset > 0) throw error
|
||||||
// An older gateway has no whole-library route. Its home response is a
|
// An older gateway has no whole-library route, and one older still
|
||||||
// useful first shelf and, crucially, is not claimed to be pageable.
|
// refuses the mixed type. Its home response is a useful first shelf
|
||||||
|
// and, crucially, is not claimed to be pageable.
|
||||||
val home = getHome(limit)
|
val home = getHome(limit)
|
||||||
|
val types = embyItemType.split(',')
|
||||||
val items = (home.rows.asSequence().flatMap { it.items.asSequence() } +
|
val items = (home.rows.asSequence().flatMap { it.items.asSequence() } +
|
||||||
home.continueWatching.asSequence() + home.nextUp.asSequence() +
|
home.continueWatching.asSequence() + home.nextUp.asSequence() +
|
||||||
home.favorites.asSequence() + home.latestMovies.asSequence())
|
home.favorites.asSequence() + home.latestMovies.asSequence())
|
||||||
.filter { it.type.equals(embyItemType, ignoreCase = true) }
|
.filter { candidate -> types.any { candidate.type.equals(it, ignoreCase = true) } }
|
||||||
.distinctBy(BaseItem::id)
|
.distinctBy(BaseItem::id)
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.toList()
|
.toList()
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ data class NavigationLabels(
|
|||||||
val search: String,
|
val search: String,
|
||||||
val movies: String,
|
val movies: String,
|
||||||
val tvShows: String,
|
val tvShows: String,
|
||||||
|
// Defaulted, unlike its neighbours: a configuration document published before the
|
||||||
|
// Genres destination existed carries no such key, and a required field would make an
|
||||||
|
// otherwise valid document fail to decode and take every other label down with it.
|
||||||
|
val genres: String = "Genres",
|
||||||
val tvCalendar: String,
|
val tvCalendar: String,
|
||||||
val favourites: String,
|
val favourites: String,
|
||||||
val user: String,
|
val user: String,
|
||||||
@@ -103,6 +107,7 @@ object BundledRemoteConfig {
|
|||||||
search = "Search",
|
search = "Search",
|
||||||
movies = "Movies",
|
movies = "Movies",
|
||||||
tvShows = "TV Shows",
|
tvShows = "TV Shows",
|
||||||
|
genres = "Genres",
|
||||||
tvCalendar = "TV Calendar",
|
tvCalendar = "TV Calendar",
|
||||||
favourites = "Favourites",
|
favourites = "Favourites",
|
||||||
user = "User",
|
user = "User",
|
||||||
@@ -233,6 +238,7 @@ internal fun validateRemoteConfig(document: MembyRemoteConfig, appVersion: Strin
|
|||||||
copy.navigation.search,
|
copy.navigation.search,
|
||||||
copy.navigation.movies,
|
copy.navigation.movies,
|
||||||
copy.navigation.tvShows,
|
copy.navigation.tvShows,
|
||||||
|
copy.navigation.genres,
|
||||||
copy.navigation.tvCalendar,
|
copy.navigation.tvCalendar,
|
||||||
copy.navigation.favourites,
|
copy.navigation.favourites,
|
||||||
copy.navigation.user,
|
copy.navigation.user,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ import androidx.compose.material.icons.filled.ChevronLeft
|
|||||||
import androidx.compose.material.icons.filled.ChevronRight
|
import androidx.compose.material.icons.filled.ChevronRight
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||||
|
import androidx.compose.material.icons.filled.GridView
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.LiveTv
|
import androidx.compose.material.icons.filled.LiveTv
|
||||||
@@ -173,6 +174,9 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
|
|||||||
SEARCH("Search", Icons.Default.Search),
|
SEARCH("Search", Icons.Default.Search),
|
||||||
MOVIES("Movies", Icons.Default.Movie),
|
MOVIES("Movies", Icons.Default.Movie),
|
||||||
SHOWS("TV Shows", Icons.Default.Tv),
|
SHOWS("TV Shows", Icons.Default.Tv),
|
||||||
|
// The catalogue, browsed by genre rather than by shelf. Hidden unless the gateway has
|
||||||
|
// the genre browser on — see [TvNavigationRail]'s genresEnabled.
|
||||||
|
GENRES("Genres", Icons.Default.GridView),
|
||||||
// Sonarr's schedule, a month at a time. Hidden unless the gateway says the household
|
// Sonarr's schedule, a month at a time. Hidden unless the gateway says the household
|
||||||
// has one — see [TvNavigationRail]'s calendarEnabled.
|
// has one — see [TvNavigationRail]'s calendarEnabled.
|
||||||
CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
|
CALENDAR("TV Calendar", Icons.Default.CalendarMonth),
|
||||||
@@ -185,11 +189,15 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
|
|||||||
* The user switcher is a launcher action, not a browsing destination. Keep it pinned above
|
* The user switcher is a launcher action, not a browsing destination. Keep it pinned above
|
||||||
* Home while the destinations below it may change with server capabilities.
|
* Home while the destinations below it may change with server capabilities.
|
||||||
*/
|
*/
|
||||||
internal fun navigationRailItems(calendarEnabled: Boolean): List<BrowseDestination> =
|
internal fun navigationRailItems(
|
||||||
|
calendarEnabled: Boolean,
|
||||||
|
genresEnabled: Boolean = true,
|
||||||
|
): List<BrowseDestination> =
|
||||||
listOf(BrowseDestination.PROFILES) + BrowseDestination.entries.filter {
|
listOf(BrowseDestination.PROFILES) + BrowseDestination.entries.filter {
|
||||||
it != BrowseDestination.PROFILES &&
|
it != BrowseDestination.PROFILES &&
|
||||||
it != BrowseDestination.SETTINGS &&
|
it != BrowseDestination.SETTINGS &&
|
||||||
(it != BrowseDestination.CALENDAR || calendarEnabled)
|
(it != BrowseDestination.CALENDAR || calendarEnabled) &&
|
||||||
|
(it != BrowseDestination.GENRES || genresEnabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
|
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
|
||||||
@@ -264,6 +272,7 @@ fun TvNavigationRail(
|
|||||||
activeUsername: String = "",
|
activeUsername: String = "",
|
||||||
activeProfileInitials: String = "",
|
activeProfileInitials: String = "",
|
||||||
calendarEnabled: Boolean = false,
|
calendarEnabled: Boolean = false,
|
||||||
|
genresEnabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
var railHasFocus by remember { mutableStateOf(false) }
|
var railHasFocus by remember { mutableStateOf(false) }
|
||||||
val logoScale = remember { Animatable(0.72f) }
|
val logoScale = remember { Animatable(0.72f) }
|
||||||
@@ -334,12 +343,16 @@ fun TvNavigationRail(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
|
// Memby's own icon rather than Emby's mark: the rail is Memby's chrome, and
|
||||||
|
// the one place the app names itself should not be somebody else's logo.
|
||||||
|
// It carries its own colour and is deliberately not tinted — this is the
|
||||||
|
// app's icon, the same one the launcher on the television shows, so a
|
||||||
|
// seasonal palette repainting it would make it a different mark.
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(R.drawable.emby_logo),
|
painter = painterResource(R.drawable.memby_mark),
|
||||||
contentDescription = "Memby",
|
contentDescription = "Memby",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(32.dp)
|
.size(30.dp)
|
||||||
.height(27.dp)
|
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = logoScale.value
|
scaleX = logoScale.value
|
||||||
scaleY = logoScale.value
|
scaleY = logoScale.value
|
||||||
@@ -371,7 +384,9 @@ fun TvNavigationRail(
|
|||||||
// A destination with nothing behind it is worse than one fewer: the calendar
|
// A destination with nothing behind it is worse than one fewer: the calendar
|
||||||
// needs the gateway and a Sonarr, and a household with neither would otherwise
|
// needs the gateway and a Sonarr, and a household with neither would otherwise
|
||||||
// carry a rail item that only ever opens an apology.
|
// carry a rail item that only ever opens an apology.
|
||||||
val destinations = remember(calendarEnabled) { navigationRailItems(calendarEnabled) }
|
val destinations = remember(calendarEnabled, genresEnabled) {
|
||||||
|
navigationRailItems(calendarEnabled, genresEnabled)
|
||||||
|
}
|
||||||
// Up and Down are explicit because the profile entry is an action while every
|
// Up and Down are explicit because the profile entry is an action while every
|
||||||
// item beneath it changes the current destination. Leaving this to spatial
|
// item beneath it changes the current destination. Leaving this to spatial
|
||||||
// search allowed content behind the expanded rail to win occasionally.
|
// search allowed content behind the expanded rail to win occasionally.
|
||||||
@@ -449,6 +464,7 @@ private fun configuredNavigationLabel(
|
|||||||
BrowseDestination.SEARCH -> labels.search
|
BrowseDestination.SEARCH -> labels.search
|
||||||
BrowseDestination.MOVIES -> labels.movies
|
BrowseDestination.MOVIES -> labels.movies
|
||||||
BrowseDestination.SHOWS -> labels.tvShows
|
BrowseDestination.SHOWS -> labels.tvShows
|
||||||
|
BrowseDestination.GENRES -> labels.genres
|
||||||
BrowseDestination.CALENDAR -> labels.tvCalendar
|
BrowseDestination.CALENDAR -> labels.tvCalendar
|
||||||
BrowseDestination.FAVORITES -> labels.favourites
|
BrowseDestination.FAVORITES -> labels.favourites
|
||||||
BrowseDestination.PROFILES -> labels.user
|
BrowseDestination.PROFILES -> labels.user
|
||||||
|
|||||||
@@ -2034,9 +2034,19 @@ private fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(genreBrowserEnabled) {
|
LaunchedEffect(genreBrowserEnabled) {
|
||||||
if (!genreBrowserEnabled && genreBrowseItemType != null) {
|
if (genreBrowserEnabled) return@LaunchedEffect
|
||||||
|
// A set standing on the Genres destination when the operator switches it off is
|
||||||
|
// moved to Home, the stance the calendar takes: the rail entry goes with the
|
||||||
|
// feature, so leaving it there would strand the viewer on a page nothing can
|
||||||
|
// navigate back to.
|
||||||
|
val strandedOnDestination = selectedDestination == BrowseDestination.GENRES
|
||||||
|
if (genreBrowseItemType != null || strandedOnDestination) {
|
||||||
genreBrowseItemType = null
|
genreBrowseItemType = null
|
||||||
genreBrowseInitialCategoryId = null
|
genreBrowseInitialCategoryId = null
|
||||||
|
if (strandedOnDestination) {
|
||||||
|
selectedDestination = BrowseDestination.HOME
|
||||||
|
railFocusDestination = BrowseDestination.HOME
|
||||||
|
}
|
||||||
kotlinx.coroutines.delay(16L)
|
kotlinx.coroutines.delay(16L)
|
||||||
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
|
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
|
||||||
}
|
}
|
||||||
@@ -2558,6 +2568,7 @@ private fun HomeScreen(
|
|||||||
activeUsername = settings.username.orEmpty(),
|
activeUsername = settings.username.orEmpty(),
|
||||||
activeProfileInitials = settings.profileInitials,
|
activeProfileInitials = settings.profileInitials,
|
||||||
calendarEnabled = tvCalendarEnabled,
|
calendarEnabled = tvCalendarEnabled,
|
||||||
|
genresEnabled = genreBrowserEnabled,
|
||||||
)
|
)
|
||||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -2677,6 +2688,48 @@ private fun HomeScreen(
|
|||||||
return@BoxWithConstraints
|
return@BoxWithConstraints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The rail's own Genres destination browses the catalogue, films and shows
|
||||||
|
// together — a household browses "Comedy", not "comedy films". The Movies
|
||||||
|
// and TV Series pages open the same screen through genreBrowseItemType
|
||||||
|
// below, naming a type so neither of those grids can cross media types.
|
||||||
|
if (selectedDestination == BrowseDestination.GENRES) {
|
||||||
|
GenreBrowseScreen(
|
||||||
|
itemType = com.ponzischeme89.memby.ui.genre.ALL_MEDIA_ITEM_TYPE,
|
||||||
|
initialCategoryId = com.ponzischeme89.memby.ui.genre.ALL_MEDIA_CATEGORY_ID,
|
||||||
|
favouriteStates = favoriteChanges,
|
||||||
|
playedStates = playedChanges,
|
||||||
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
|
contentFocusRequester = contentFocusRequester,
|
||||||
|
returnFocusItemId = returnItemId.takeIf { returnRowId == GENRE_BROWSER_ROW_ID },
|
||||||
|
returnFocusRequester = cardReturnFocusRequester,
|
||||||
|
onItemFocused = homeViewModel::focusItem,
|
||||||
|
onItemSelected = { item ->
|
||||||
|
returnRowId = GENRE_BROWSER_ROW_ID
|
||||||
|
returnItemId = item.id
|
||||||
|
destinationFocus[BrowseDestination.GENRES] =
|
||||||
|
GENRE_BROWSER_ROW_ID to item.id
|
||||||
|
homeViewModel.focusItem(item)
|
||||||
|
homeViewModel.trackJourney(
|
||||||
|
category = "content", action = "open", screen = "genres",
|
||||||
|
feature = "genre_browse", source = "genre_results", target = "details",
|
||||||
|
itemName = item.name, itemType = item.type,
|
||||||
|
)
|
||||||
|
detailsAiringNotice = null
|
||||||
|
detailsItem = item
|
||||||
|
},
|
||||||
|
onContentFocused = { navigationExpanded = false },
|
||||||
|
onClose = {
|
||||||
|
selectedDestination = BrowseDestination.HOME
|
||||||
|
railFocusDestination = BrowseDestination.HOME
|
||||||
|
scope.launch {
|
||||||
|
kotlinx.coroutines.delay(16L)
|
||||||
|
runCatching { contentFocusRequester.requestFocus() }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return@BoxWithConstraints
|
||||||
|
}
|
||||||
|
|
||||||
genreBrowseItemType?.let { itemType ->
|
genreBrowseItemType?.let { itemType ->
|
||||||
GenreBrowseScreen(
|
GenreBrowseScreen(
|
||||||
itemType = itemType,
|
itemType = itemType,
|
||||||
@@ -2701,6 +2754,7 @@ private fun HomeScreen(
|
|||||||
detailsAiringNotice = null
|
detailsAiringNotice = null
|
||||||
detailsItem = item
|
detailsItem = item
|
||||||
},
|
},
|
||||||
|
onContentFocused = { navigationExpanded = false },
|
||||||
onClose = {
|
onClose = {
|
||||||
genreBrowseItemType = null
|
genreBrowseItemType = null
|
||||||
genreBrowseInitialCategoryId = null
|
genreBrowseInitialCategoryId = null
|
||||||
@@ -4541,6 +4595,7 @@ internal fun homeRowsFor(
|
|||||||
// Search draws its own pane; the rail destinations that open an overlay have no
|
// Search draws its own pane; the rail destinations that open an overlay have no
|
||||||
// rows of their own either.
|
// rows of their own either.
|
||||||
BrowseDestination.SEARCH -> emptyList()
|
BrowseDestination.SEARCH -> emptyList()
|
||||||
|
BrowseDestination.GENRES -> emptyList()
|
||||||
BrowseDestination.CALENDAR -> emptyList()
|
BrowseDestination.CALENDAR -> emptyList()
|
||||||
BrowseDestination.PROFILES -> emptyList()
|
BrowseDestination.PROFILES -> emptyList()
|
||||||
BrowseDestination.SETTINGS -> emptyList()
|
BrowseDestination.SETTINGS -> emptyList()
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
package com.ponzischeme89.memby.ui.genre
|
package com.ponzischeme89.memby.ui.genre
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -13,22 +16,25 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed as rowItemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||||
import androidx.compose.material.icons.filled.AutoAwesome
|
import androidx.compose.material.icons.filled.AutoAwesome
|
||||||
import androidx.compose.material.icons.filled.Bolt
|
import androidx.compose.material.icons.filled.Bolt
|
||||||
@@ -48,19 +54,32 @@ import androidx.compose.material.icons.filled.VisibilityOff
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusProperties
|
import androidx.compose.ui.focus.focusProperties
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
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.onKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -72,11 +91,16 @@ import com.ponzischeme89.memby.data.model.BaseItem
|
|||||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||||
import com.ponzischeme89.memby.ui.PosterGridCard
|
import com.ponzischeme89.memby.ui.PosterGridCard
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyAccentInk
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GenreDiscoveryStrip(
|
fun GenreDiscoveryStrip(
|
||||||
@@ -183,6 +207,36 @@ private fun genreVisual(icon: GenreCategoryIcon): GenreVisual = when (icon) {
|
|||||||
GenreCategoryIcon.REALITY -> GenreVisual(Icons.Default.LiveTv, Color(0xFFC2410C))
|
GenreCategoryIcon.REALITY -> GenreVisual(Icons.Default.LiveTv, Color(0xFFC2410C))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a genre may sit under focus before it is asked for.
|
||||||
|
*
|
||||||
|
* Focus is selection in this rail, and a held D-pad travels it several items a second.
|
||||||
|
* Without this every genre passed through would be a request, and the one the viewer
|
||||||
|
* actually stopped on would queue behind fifteen answers nobody is waiting for. It is
|
||||||
|
* deliberately short: at this length a deliberate press still reads as instant, and by
|
||||||
|
* the time the wait is over a neighbour has usually been warmed anyway.
|
||||||
|
*/
|
||||||
|
private const val GENRE_SELECT_DEBOUNCE_MS = 130L
|
||||||
|
|
||||||
|
/** How many rows of placeholder cards stand in for a genre that has not answered yet. */
|
||||||
|
private const val GENRE_PLACEHOLDER_ROWS = 3
|
||||||
|
|
||||||
|
internal val GenreRailWidth = 214.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The genres destination: a secondary navigation rail beside the launcher's own.
|
||||||
|
*
|
||||||
|
* The hierarchy is *primary rail → genre rail → genre content*, and each level is one
|
||||||
|
* D-pad press from the one beside it. Changing genre is a change of the pane on the right
|
||||||
|
* and never a navigation: the screen is not rebuilt, the rail keeps its place, and the
|
||||||
|
* grid a viewer comes back to is the grid they left.
|
||||||
|
*
|
||||||
|
* Focus is selection in the genre rail, the stance the detail page's tab strip takes. A
|
||||||
|
* remote has no hover, so a rail that highlighted one genre while a different one stayed
|
||||||
|
* open would need a second press to mean anything and would show content contradicting
|
||||||
|
* the highlight — and browsing a catalogue is exactly the case where pressing twice per
|
||||||
|
* genre is what stops somebody browsing.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun GenreBrowseScreen(
|
fun GenreBrowseScreen(
|
||||||
itemType: String,
|
itemType: String,
|
||||||
@@ -197,221 +251,247 @@ fun GenreBrowseScreen(
|
|||||||
onItemSelected: (BaseItem) -> Unit,
|
onItemSelected: (BaseItem) -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onContentFocused: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val mediaLabel = if (itemType.equals("Series", ignoreCase = true)) "TV shows" else "movies"
|
val mediaLabel = remember(itemType) { genreMediaLabel(itemType) }
|
||||||
val browseViewModel: GenreBrowseViewModel = viewModel(
|
val browseViewModel: GenreBrowseViewModel = viewModel(
|
||||||
key = "genre-browse-${itemType.lowercase()}",
|
key = "genre-browse-${itemType.lowercase().ifEmpty { "all" }}",
|
||||||
factory = remember(itemType) {
|
factory = remember(itemType) {
|
||||||
GenreBrowseViewModelFactory(ServiceLocator.repository, itemType)
|
GenreBrowseViewModelFactory(ServiceLocator.repository, itemType)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val state by browseViewModel.state.collectAsStateWithLifecycle()
|
val state by browseViewModel.state.collectAsStateWithLifecycle()
|
||||||
val gridState = rememberLazyGridState()
|
val scope = rememberCoroutineScope()
|
||||||
val tabState = androidx.compose.foundation.lazy.rememberLazyListState()
|
|
||||||
val selectedCategory = remember(state.selectedCategoryId, itemType) {
|
|
||||||
genreCategory(itemType, state.selectedCategoryId)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(initialCategoryId) { browseViewModel.selectCategory(initialCategoryId) }
|
// The genre the remote is on, kept apart from the view model's selection so the rail's
|
||||||
|
// own marker and the entry FocusRequester are never waiting on a request. The pane
|
||||||
|
// follows the selection instead, because it labels the grid rather than the remote.
|
||||||
|
var activeCategoryId by remember(initialCategoryId) {
|
||||||
|
mutableStateOf(state.selectedCategoryId ?: initialCategoryId)
|
||||||
|
}
|
||||||
|
var contentHasFocus by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// One grid state per genre, so returning to a genre returns to where it was left
|
||||||
|
// rather than to the top of it. Bounded by the catalogue, which is a fixed list.
|
||||||
|
val gridStates = remember(itemType) { mutableMapOf<String, LazyGridState>() }
|
||||||
|
val focusedCardIndexes = remember(itemType) { mutableMapOf<String, Int>() }
|
||||||
|
val gridEntryFocusRequester = remember { FocusRequester() }
|
||||||
|
// Which card [gridEntryFocusRequester] is attached to. It is state rather than a value
|
||||||
|
// derived from the map above, because the card a genre was left on changes as the
|
||||||
|
// viewer travels the grid and a requester pinned to where they *entered* would send
|
||||||
|
// the next press back to the top-left corner. Written by the press that uses it, so
|
||||||
|
// travelling the grid costs no recomposition of it.
|
||||||
|
var gridEntryIndex by remember(itemType) { mutableStateOf(0) }
|
||||||
|
|
||||||
|
val shownCategoryId = state.selectedCategoryId
|
||||||
|
val selectedCategory = remember(shownCategoryId, itemType) {
|
||||||
|
genreCategory(itemType, shownCategoryId)
|
||||||
|
}
|
||||||
|
val gridState = gridStates.getOrPut(shownCategoryId ?: initialCategoryId) { LazyGridState() }
|
||||||
|
|
||||||
|
LaunchedEffect(activeCategoryId) {
|
||||||
|
// See [GENRE_SELECT_DEBOUNCE_MS]: travelling past a genre must not ask for it.
|
||||||
|
// Only a *change* is a candidate for that — the genre the page opens on is the one
|
||||||
|
// thing somebody is definitely waiting for, so it is asked for at once.
|
||||||
|
if (state.selectedCategoryId != null) kotlinx.coroutines.delay(GENRE_SELECT_DEBOUNCE_MS)
|
||||||
|
browseViewModel.selectCategory(activeCategoryId)
|
||||||
|
}
|
||||||
LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) }
|
LaunchedEffect(favouriteStates) { browseViewModel.applyFavouriteStates(favouriteStates) }
|
||||||
LaunchedEffect(playedStates) { browseViewModel.applyPlayedStates(playedStates) }
|
LaunchedEffect(playedStates) { browseViewModel.applyPlayedStates(playedStates) }
|
||||||
LaunchedEffect(state.selectedCategoryId) {
|
|
||||||
val index = state.categories.indexOfFirst { it.id == state.selectedCategoryId }.coerceAtLeast(0)
|
|
||||||
if (state.selectedCategoryId != null) {
|
|
||||||
gridState.scrollToItem(0)
|
|
||||||
tabState.scrollToItem(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
kotlinx.coroutines.delay(32L)
|
kotlinx.coroutines.delay(32L)
|
||||||
runCatching { contentFocusRequester.requestFocus() }
|
runCatching { contentFocusRequester.requestFocus() }
|
||||||
}
|
}
|
||||||
BackHandler(onBack = onClose)
|
// Back steps out of the grid before it steps off the page — one press per level, the
|
||||||
|
// rule the search pane and the calendar already follow.
|
||||||
BoxWithConstraints(modifier.fillMaxSize().background(MembySurface)) {
|
BackHandler {
|
||||||
val columns = when {
|
if (contentHasFocus) {
|
||||||
maxWidth >= 1080.dp -> 7
|
runCatching { contentFocusRequester.requestFocus() }
|
||||||
maxWidth >= 860.dp -> 6
|
} else {
|
||||||
maxWidth >= 680.dp -> 5
|
onClose()
|
||||||
else -> 4
|
|
||||||
}
|
}
|
||||||
val spacing = 16.dp
|
}
|
||||||
val horizontalPadding = 36.dp
|
|
||||||
val cardWidth = ((maxWidth - horizontalPadding * 2 - spacing * (columns - 1)) / columns)
|
|
||||||
.coerceAtLeast(112.dp)
|
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(top = 24.dp)) {
|
/** Moves focus into the grid, at the card this genre was left on. */
|
||||||
Row(
|
fun enterGrid(): Boolean {
|
||||||
modifier = Modifier.padding(horizontal = horizontalPadding),
|
val items = state.items
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
if (items.isEmpty()) return false
|
||||||
) {
|
// Where this genre was left comes first, and the card a detail page was opened
|
||||||
FocusScaleContainer(
|
// from is the fallback. The other order goes stale: returnFocusItemId is not
|
||||||
onFocused = {},
|
// cleared once its restore is done, so it would keep pulling every later press
|
||||||
onClick = onClose,
|
// back to a card the viewer has long since scrolled past.
|
||||||
contentDescription = "Back to $mediaLabel",
|
val remembered = focusedCardIndexes[shownCategoryId]?.takeIf { it in items.indices }
|
||||||
modifier = Modifier
|
?: returnFocusItemId?.let { id -> items.indexOfFirst { it.id == id }.takeIf { i -> i >= 0 } }
|
||||||
.size(42.dp)
|
?: 0
|
||||||
.focusProperties { left = navigationFocusRequester }
|
gridEntryIndex = remembered
|
||||||
.background(MembySurfaceRaised, RoundedCornerShape(10.dp)),
|
scope.launch {
|
||||||
) { focused ->
|
// A card outside the composed window cannot be focused, so the grid is put
|
||||||
Box(
|
// back where it was before its card is asked for — and the requester has to
|
||||||
Modifier
|
// have moved to that card first, which is one recomposition away.
|
||||||
.fillMaxSize()
|
runCatching { gridState.scrollToItem(remembered) }
|
||||||
.background(if (focused) Color.White else Color.Transparent, RoundedCornerShape(10.dp)),
|
repeat(3) {
|
||||||
contentAlignment = Alignment.Center,
|
kotlinx.coroutines.delay(16L)
|
||||||
) {
|
if (runCatching { gridEntryFocusRequester.requestFocus() }.isSuccess) return@launch
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (focused) MembySurface else Color.White,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(14.dp))
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
"Browse $mediaLabel",
|
|
||||||
color = Color.White,
|
|
||||||
fontSize = 26.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
selectedCategory.label,
|
|
||||||
color = MembyMutedText,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Box(modifier.fillMaxSize().background(MembySurface)) {
|
||||||
LazyRow(
|
Row(Modifier.fillMaxSize()) {
|
||||||
state = tabState,
|
GenreRail(
|
||||||
contentPadding = PaddingValues(horizontal = horizontalPadding),
|
categories = state.categories,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
activeCategoryId = activeCategoryId,
|
||||||
|
navigationFocusRequester = navigationFocusRequester,
|
||||||
|
activeFocusRequester = contentFocusRequester,
|
||||||
|
onCategoryFocused = { id ->
|
||||||
|
onContentFocused()
|
||||||
|
activeCategoryId = id
|
||||||
|
},
|
||||||
|
onEnterContent = ::enterGrid,
|
||||||
|
)
|
||||||
|
BoxWithConstraints(
|
||||||
|
Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.onFocusChanged { contentHasFocus = it.hasFocus },
|
||||||
) {
|
) {
|
||||||
rowItemsIndexed(state.categories, key = { _, category -> category.id }) { index, category ->
|
val horizontalPadding = 40.dp
|
||||||
val selected = category.id == state.selectedCategoryId
|
val spacing = 18.dp
|
||||||
FocusScaleContainer(
|
val columns = when {
|
||||||
onFocused = {},
|
maxWidth >= 1000.dp -> 6
|
||||||
onClick = {
|
maxWidth >= 820.dp -> 5
|
||||||
if (!selected) {
|
maxWidth >= 620.dp -> 4
|
||||||
browseViewModel.selectCategory(category.id)
|
else -> 3
|
||||||
}
|
}
|
||||||
},
|
val cardWidth = ((maxWidth - horizontalPadding * 2 - spacing * (columns - 1)) / columns)
|
||||||
contentDescription = "Browse ${category.label}",
|
.coerceAtLeast(104.dp)
|
||||||
modifier = Modifier
|
|
||||||
.then(if (selected) Modifier.focusRequester(contentFocusRequester) else Modifier)
|
Column(Modifier.fillMaxSize()) {
|
||||||
.focusProperties { if (index == 0) left = navigationFocusRequester }
|
Spacer(Modifier.height(44.dp))
|
||||||
.background(
|
Column(Modifier.padding(horizontal = horizontalPadding)) {
|
||||||
when {
|
|
||||||
selected -> MembyAccent
|
|
||||||
else -> MembySurfaceRaised.copy(alpha = 0.72f)
|
|
||||||
},
|
|
||||||
RoundedCornerShape(18.dp),
|
|
||||||
)
|
|
||||||
.border(
|
|
||||||
1.dp,
|
|
||||||
if (selected) Color.White.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.06f),
|
|
||||||
RoundedCornerShape(18.dp),
|
|
||||||
),
|
|
||||||
) { focused ->
|
|
||||||
Text(
|
Text(
|
||||||
category.label,
|
selectedCategory.label,
|
||||||
color = when {
|
color = Color.White,
|
||||||
focused -> MembySurface
|
fontSize = 34.sp,
|
||||||
selected -> Color.White
|
fontWeight = FontWeight.Bold,
|
||||||
else -> MembyMutedText
|
maxLines = 1,
|
||||||
},
|
overflow = TextOverflow.Ellipsis,
|
||||||
fontSize = 14.sp,
|
)
|
||||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
|
Text(
|
||||||
modifier = Modifier
|
mediaLabel,
|
||||||
.background(
|
color = MembyQuietText,
|
||||||
if (focused) Color.White else Color.Transparent,
|
fontSize = 13.sp,
|
||||||
RoundedCornerShape(18.dp),
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
|
||||||
.padding(horizontal = 17.dp, vertical = 9.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
Spacer(Modifier.height(18.dp))
|
||||||
}
|
|
||||||
Spacer(Modifier.height(18.dp))
|
|
||||||
|
|
||||||
when {
|
val gridPadding = PaddingValues(
|
||||||
state.selectedCategoryId == null -> GenreMessage("Loading $mediaLabel…")
|
start = horizontalPadding,
|
||||||
state.isLoading && state.items.isEmpty() -> GenreMessage(
|
end = horizontalPadding,
|
||||||
"Loading ${selectedCategory.label}…",
|
bottom = 72.dp,
|
||||||
)
|
)
|
||||||
state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
|
when {
|
||||||
message = state.errorMessage.orEmpty(),
|
// Placeholders rather than a spinner over the page: the heading,
|
||||||
onRetry = browseViewModel::retry,
|
// the rail and the shape of the grid all stay exactly where they
|
||||||
)
|
// are, so nothing under the viewer's thumb moves when the answer
|
||||||
state.items.isEmpty() -> GenreMessage(
|
// lands.
|
||||||
if (selectedCategory.genres.isEmpty()) "No $mediaLabel were found."
|
state.isLoading && state.items.isEmpty() -> GenrePlaceholderGrid(
|
||||||
else "No ${selectedCategory.label.lowercase()} $mediaLabel were found.",
|
columns = columns,
|
||||||
)
|
cardWidth = cardWidth,
|
||||||
else -> {
|
spacing = spacing,
|
||||||
LaunchedEffect(gridState, state.items.size, state.canLoadMore) {
|
contentPadding = gridPadding,
|
||||||
snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
|
)
|
||||||
.distinctUntilChanged()
|
state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
|
||||||
.collect { last ->
|
message = state.errorMessage.orEmpty(),
|
||||||
if (state.canLoadMore && last >= state.items.size - columns * 2) {
|
onRetry = browseViewModel::retry,
|
||||||
browseViewModel.loadMore()
|
navigationFocusRequester = contentFocusRequester,
|
||||||
}
|
)
|
||||||
}
|
state.items.isEmpty() -> GenreMessage(
|
||||||
}
|
if (selectedCategory.genres.isEmpty()) {
|
||||||
LazyVerticalGrid(
|
"No ${mediaLabel.lowercase()} were found."
|
||||||
columns = GridCells.Fixed(columns),
|
|
||||||
state = gridState,
|
|
||||||
contentPadding = PaddingValues(
|
|
||||||
start = horizontalPadding,
|
|
||||||
end = horizontalPadding,
|
|
||||||
bottom = 72.dp,
|
|
||||||
),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
itemsIndexed(
|
|
||||||
state.items,
|
|
||||||
key = { _, item -> item.id },
|
|
||||||
contentType = { _, _ -> "genre-poster" },
|
|
||||||
) { index, item ->
|
|
||||||
val favourite = favouriteStates[item.id]
|
|
||||||
val displayedItem = if (favourite == null || favourite == item.isFavorite) {
|
|
||||||
item
|
|
||||||
} else {
|
} else {
|
||||||
item.withFavourite(favourite)
|
"No ${selectedCategory.label.lowercase()} ${mediaLabel.lowercase()} were found."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
LaunchedEffect(gridState, state.items.size, state.canLoadMore) {
|
||||||
|
snapshotFlow {
|
||||||
|
gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { last ->
|
||||||
|
if (state.canLoadMore && last >= state.items.size - columns * 2) {
|
||||||
|
browseViewModel.loadMore()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
PosterGridCard(
|
LazyVerticalGrid(
|
||||||
item = displayedItem,
|
columns = GridCells.Fixed(columns),
|
||||||
width = cardWidth,
|
state = gridState,
|
||||||
onFocused = { onItemFocused(displayedItem) },
|
contentPadding = gridPadding,
|
||||||
onClick = { onItemSelected(displayedItem) },
|
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||||
onLongClick = { onItemSelected(displayedItem) },
|
verticalArrangement = Arrangement.spacedBy(22.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize(),
|
||||||
.then(
|
) {
|
||||||
if (item.id == returnFocusItemId) {
|
itemsIndexed(
|
||||||
Modifier.focusRequester(returnFocusRequester)
|
state.items,
|
||||||
} else {
|
key = { _, item -> item.id },
|
||||||
Modifier
|
contentType = { _, _ -> "genre-poster" },
|
||||||
|
) { index, item ->
|
||||||
|
val favourite = favouriteStates[item.id]
|
||||||
|
val displayedItem = if (favourite == null || favourite == item.isFavorite) {
|
||||||
|
item
|
||||||
|
} else {
|
||||||
|
item.withFavourite(favourite)
|
||||||
|
}
|
||||||
|
PosterGridCard(
|
||||||
|
item = displayedItem,
|
||||||
|
width = cardWidth,
|
||||||
|
onFocused = {
|
||||||
|
onContentFocused()
|
||||||
|
shownCategoryId?.let { focusedCardIndexes[it] = index }
|
||||||
|
onItemFocused(displayedItem)
|
||||||
},
|
},
|
||||||
|
onClick = { onItemSelected(displayedItem) },
|
||||||
|
onLongClick = { onItemSelected(displayedItem) },
|
||||||
|
modifier = Modifier
|
||||||
|
.then(
|
||||||
|
if (item.id == returnFocusItemId) {
|
||||||
|
Modifier.focusRequester(returnFocusRequester)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (index == gridEntryIndex) {
|
||||||
|
Modifier.focusRequester(gridEntryFocusRequester)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.focusProperties {
|
||||||
|
// Left out of the first column is the way
|
||||||
|
// back to the genre this grid belongs to,
|
||||||
|
// which is where that requester is attached.
|
||||||
|
if (index % columns == 0) left = contentFocusRequester
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.focusProperties {
|
}
|
||||||
if (index % columns == 0) left = navigationFocusRequester
|
if (state.isLoadingMore) {
|
||||||
},
|
items(GENRE_PLACEHOLDER_ROWS * columns) {
|
||||||
)
|
GenrePlaceholderCard(cardWidth)
|
||||||
}
|
}
|
||||||
if (state.isLoadingMore) {
|
} else if (state.errorMessage != null) {
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
GenreMessage("Loading more…")
|
GenreRetry(
|
||||||
}
|
message = state.errorMessage.orEmpty(),
|
||||||
} else if (state.errorMessage != null) {
|
onRetry = browseViewModel::retry,
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
navigationFocusRequester = contentFocusRequester,
|
||||||
GenreRetry(
|
)
|
||||||
message = state.errorMessage.orEmpty(),
|
}
|
||||||
onRetry = browseViewModel::retry,
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -421,17 +501,246 @@ fun GenreBrowseScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The secondary rail.
|
||||||
|
*
|
||||||
|
* Its separation from the launcher's own rail is tonal rather than structural — a raised
|
||||||
|
* translucent surface fading into the content background, with a hairline where the two
|
||||||
|
* meet. That is enough to read the hierarchy at three metres without a second black
|
||||||
|
* column making the screen look like two applications side by side.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun GenreRail(
|
||||||
|
categories: List<GenreCategory>,
|
||||||
|
activeCategoryId: String,
|
||||||
|
navigationFocusRequester: FocusRequester,
|
||||||
|
activeFocusRequester: FocusRequester,
|
||||||
|
onCategoryFocused: (String) -> Unit,
|
||||||
|
onEnterContent: () -> Boolean,
|
||||||
|
/**
|
||||||
|
* Draws one genre as though the remote were on it.
|
||||||
|
*
|
||||||
|
* Robolectric's window never takes focus, and the difference between the genre in
|
||||||
|
* force and the genre under the thumb is the whole of what this rail has to say — a
|
||||||
|
* capture that could only ever photograph the first would prove nothing.
|
||||||
|
*/
|
||||||
|
focusForCapture: String? = null,
|
||||||
|
) {
|
||||||
|
val railState = rememberLazyListState()
|
||||||
|
// Only on arrival: once the viewer is in the rail, the lazy list scrolls itself as
|
||||||
|
// focus travels, and a second scroll chasing the selection fights the D-pad.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val index = categories.indexOfFirst { it.id == activeCategoryId }
|
||||||
|
if (index > 0) runCatching { railState.scrollToItem(index) }
|
||||||
|
}
|
||||||
|
val itemFocusRequesters = remember(categories) {
|
||||||
|
categories.associate { it.id to FocusRequester() }
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.width(GenreRailWidth)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
listOf(MembySurfaceRaised.copy(alpha = 0.62f), MembySurface.copy(alpha = 0f)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.drawBehind {
|
||||||
|
// The hairline the two rails meet on. Drawn rather than a bordered Box:
|
||||||
|
// it is one line and must not cost the rail a layout node.
|
||||||
|
drawRect(
|
||||||
|
color = Color.White.copy(alpha = 0.07f),
|
||||||
|
topLeft = androidx.compose.ui.geometry.Offset(size.width - 1f, 0f),
|
||||||
|
size = androidx.compose.ui.geometry.Size(1f, size.height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.focusGroup()
|
||||||
|
.onKeyEvent { event ->
|
||||||
|
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
|
||||||
|
// Right belongs to the rail as a whole: whichever genre holds focus, the
|
||||||
|
// press means "into the grid", and it has to scroll the remembered card
|
||||||
|
// back into composition before anything can be focused. A focusProperties
|
||||||
|
// target could not do either.
|
||||||
|
if (event.key == Key.DirectionRight) onEnterContent() else false
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"GENRES",
|
||||||
|
color = MembyQuietText,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = 1.6.sp,
|
||||||
|
modifier = Modifier.padding(start = 22.dp, top = 46.dp, bottom = 14.dp),
|
||||||
|
)
|
||||||
|
LazyColumn(
|
||||||
|
state = railState,
|
||||||
|
contentPadding = PaddingValues(start = 12.dp, end = 14.dp, bottom = 48.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
rowItemsIndexed(categories, key = { _, category -> category.id }) { index, category ->
|
||||||
|
val requester = itemFocusRequesters.getValue(category.id)
|
||||||
|
GenreRailItem(
|
||||||
|
category = category,
|
||||||
|
active = category.id == activeCategoryId,
|
||||||
|
focusedForCapture = category.id == focusForCapture,
|
||||||
|
onFocused = { onCategoryFocused(category.id) },
|
||||||
|
onClick = { onCategoryFocused(category.id) },
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRequester(requester)
|
||||||
|
// A second requester on the same node: the screen's entry target
|
||||||
|
// is the genre in force, and it is also what Left out of the
|
||||||
|
// grid's first column names.
|
||||||
|
.then(
|
||||||
|
if (category.id == activeCategoryId) {
|
||||||
|
Modifier.focusRequester(activeFocusRequester)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.focusProperties {
|
||||||
|
// Explicit, so vertical travel can never leave the rail. Left
|
||||||
|
// to spatial search, Up from the first genre lands on whichever
|
||||||
|
// item of the launcher's rail happens to be beside it.
|
||||||
|
left = navigationFocusRequester
|
||||||
|
up = if (index == 0) {
|
||||||
|
FocusRequester.Cancel
|
||||||
|
} else {
|
||||||
|
itemFocusRequesters.getValue(categories[index - 1].id)
|
||||||
|
}
|
||||||
|
down = if (index == categories.lastIndex) {
|
||||||
|
FocusRequester.Cancel
|
||||||
|
} else {
|
||||||
|
itemFocusRequesters.getValue(categories[index + 1].id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One genre.
|
||||||
|
*
|
||||||
|
* Focus and the genre in force are marked separately, the stance the player's subtitle
|
||||||
|
* menu takes: the option under the thumb is the only fill on the rail (accent), and the
|
||||||
|
* genre the grid beside it is showing wears a quiet plate with a bar in the accent. One
|
||||||
|
* causes the other while the viewer is in the rail, and they come apart the moment the
|
||||||
|
* viewer presses Right — which is the case the distinction exists for.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun GenreRailItem(
|
||||||
|
category: GenreCategory,
|
||||||
|
active: Boolean,
|
||||||
|
onFocused: () -> Unit,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
focusedForCapture: Boolean = false,
|
||||||
|
) {
|
||||||
|
FocusScaleContainer(
|
||||||
|
onFocused = onFocused,
|
||||||
|
onClick = onClick,
|
||||||
|
contentDescription = "Browse ${category.label}",
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) { hasFocus ->
|
||||||
|
val focused = hasFocus || focusedForCapture
|
||||||
|
// Read only inside drawBehind. The rail is travelled fast, and an animated value
|
||||||
|
// read in a composable body would recompose the item on every frame of its own
|
||||||
|
// focus animation — which on a weak box is most of what a held D-pad costs.
|
||||||
|
val emphasis = animateFloatAsState(
|
||||||
|
targetValue = if (focused) 1f else 0f,
|
||||||
|
animationSpec = tween(140),
|
||||||
|
label = "genre-rail-emphasis",
|
||||||
|
)
|
||||||
|
val plate = when {
|
||||||
|
focused -> MembyAccent
|
||||||
|
active -> MembyControlSurface
|
||||||
|
else -> Color.Transparent
|
||||||
|
}
|
||||||
|
val bar = if (focused) MembyAccentInk.copy(alpha = 0.45f) else MembyAccent
|
||||||
|
val barVisible = focused || active
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.drawBehind {
|
||||||
|
drawRoundRect(color = plate, cornerRadius = CornerRadius(MembyChipCorner.toPx()))
|
||||||
|
// The marker grows with focus rather than appearing, so travelling
|
||||||
|
// the rail reads as one moving indicator instead of a column of
|
||||||
|
// flashing bars.
|
||||||
|
val height = size.height * (0.34f + 0.42f * emphasis.value)
|
||||||
|
drawRoundRect(
|
||||||
|
color = bar,
|
||||||
|
topLeft = androidx.compose.ui.geometry.Offset(0f, (size.height - height) / 2f),
|
||||||
|
size = androidx.compose.ui.geometry.Size(3.dp.toPx(), height),
|
||||||
|
cornerRadius = CornerRadius(2.dp.toPx()),
|
||||||
|
alpha = if (barVisible) 1f else 0f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(start = 15.dp, end = 12.dp, top = 11.dp, bottom = 11.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
category.label,
|
||||||
|
color = when {
|
||||||
|
focused -> MembyAccentInk
|
||||||
|
active -> Color.White
|
||||||
|
else -> MembyMutedText
|
||||||
|
},
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = if (focused || active) FontWeight.Bold else FontWeight.Medium,
|
||||||
|
// Wrapped rather than ellipsised: "Family & Animation" is a real entry and
|
||||||
|
// a rail that hid half of its own labels would be unreadable at distance.
|
||||||
|
maxLines = 2,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenrePlaceholderGrid(
|
||||||
|
columns: Int,
|
||||||
|
cardWidth: Dp,
|
||||||
|
spacing: Dp,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
) {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(columns),
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(22.dp),
|
||||||
|
userScrollEnabled = false,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
items(GENRE_PLACEHOLDER_ROWS * columns) { GenrePlaceholderCard(cardWidth) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A card-shaped hole. Never focusable: it stands for something that is not there yet. */
|
||||||
|
@Composable
|
||||||
|
private fun GenrePlaceholderCard(width: Dp) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.width(width)
|
||||||
|
.aspectRatio(2f / 3f)
|
||||||
|
.background(MembySurfaceRaised.copy(alpha = 0.45f), RoundedCornerShape(MembyCardCorner)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GenreMessage(message: String) {
|
private fun GenreMessage(message: String) {
|
||||||
Box(Modifier.fillMaxWidth().padding(36.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxWidth().padding(40.dp), contentAlignment = Alignment.Center) {
|
||||||
Text(message, color = MembyMutedText, fontSize = 15.sp)
|
Text(message, color = MembyMutedText, fontSize = 15.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GenreRetry(message: String, onRetry: () -> Unit) {
|
private fun GenreRetry(
|
||||||
|
message: String,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
navigationFocusRequester: FocusRequester,
|
||||||
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().padding(36.dp),
|
Modifier.fillMaxWidth().padding(40.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
@@ -442,11 +751,14 @@ private fun GenreRetry(message: String, onRetry: () -> Unit) {
|
|||||||
contentDescription = "Try loading the genre again",
|
contentDescription = "Try loading the genre again",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(MembyAccent, RoundedCornerShape(9.dp))
|
.background(MembyAccent, RoundedCornerShape(9.dp))
|
||||||
|
// Left off the only control on an otherwise empty pane has to go somewhere,
|
||||||
|
// and the genre it failed for is what is beside it.
|
||||||
|
.focusProperties { left = navigationFocusRequester }
|
||||||
.semantics { contentDescription = "Try again" },
|
.semantics { contentDescription = "Try again" },
|
||||||
) { focused ->
|
) { _ ->
|
||||||
Text(
|
Text(
|
||||||
"Try again",
|
"Try again",
|
||||||
color = Color.White,
|
color = MembyAccentInk,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,12 +47,26 @@ class GenreBrowseViewModel(
|
|||||||
private var pageJob: Job? = null
|
private var pageJob: Job? = null
|
||||||
private val categoryPages = mutableMapOf<String, CachedCategoryPage>()
|
private val categoryPages = mutableMapOf<String, CachedCategoryPage>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First pages being warmed for the categories either side of the selected one.
|
||||||
|
*
|
||||||
|
* Kept apart from [pageJob] because they are cancelled by opposite events: moving to
|
||||||
|
* another genre makes the shelf's own in-flight page obsolete, and is precisely what
|
||||||
|
* a warm exists to have finished before. Keyed so that travelling the rail joins a
|
||||||
|
* warm already running rather than starting a second one for the same genre.
|
||||||
|
*/
|
||||||
|
private val warmJobs = mutableMapOf<String, Job>()
|
||||||
|
|
||||||
fun selectCategory(categoryId: String) {
|
fun selectCategory(categoryId: String) {
|
||||||
val selected = categories.firstOrNull { it.id == categoryId } ?: categories.first()
|
val selected = categories.firstOrNull { it.id == categoryId } ?: categories.first()
|
||||||
val current = state.value
|
val current = state.value
|
||||||
if (current.selectedCategoryId == selected.id && (current.items.isNotEmpty() || current.isLoading)) return
|
if (current.selectedCategoryId == selected.id && (current.items.isNotEmpty() || current.isLoading)) return
|
||||||
val cached = categoryPages[selected.id]
|
val cached = categoryPages[selected.id]
|
||||||
|
// The page in flight was for the genre being left. Its answer can no longer be
|
||||||
|
// shown, and holding the connection open would only slow the one now being asked
|
||||||
|
// for. A warm for this genre is deliberately not cancelled — it is adopted below.
|
||||||
pageJob?.cancel()
|
pageJob?.cancel()
|
||||||
|
val warming = warmJobs[selected.id]?.isActive == true
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
selectedCategoryId = selected.id,
|
selectedCategoryId = selected.id,
|
||||||
@@ -64,7 +78,30 @@ class GenreBrowseViewModel(
|
|||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (cached == null) pageJob = viewModelScope.launch { loadPage(selected, 0) }
|
if (cached == null && !warming) {
|
||||||
|
pageJob = viewModelScope.launch { loadPage(selected, 0) }
|
||||||
|
} else if (cached == null) {
|
||||||
|
pageJob = warmJobs[selected.id]
|
||||||
|
}
|
||||||
|
warmNeighbours(selected.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the first page of the categories either side of the selection.
|
||||||
|
*
|
||||||
|
* Vertical travel is the only way a genre is reached from the rail, so these two are
|
||||||
|
* the next thing that will be asked for. It costs a request each per session — the
|
||||||
|
* result is cached in [categoryPages] like any other page — and it is what makes
|
||||||
|
* moving down the rail land on a shelf that is already there rather than on a row of
|
||||||
|
* placeholders.
|
||||||
|
*/
|
||||||
|
private fun warmNeighbours(selectedId: String) {
|
||||||
|
adjacentCategoryIds(categories, selectedId).forEach { id ->
|
||||||
|
if (categoryPages.containsKey(id)) return@forEach
|
||||||
|
if (warmJobs[id]?.isActive == true) return@forEach
|
||||||
|
val category = categories.firstOrNull { it.id == id } ?: return@forEach
|
||||||
|
warmJobs[id] = viewModelScope.launch { loadPage(category, 0) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMore() {
|
fun loadMore() {
|
||||||
@@ -147,19 +184,24 @@ class GenreBrowseViewModel(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onSuccess { page ->
|
}.onSuccess { page ->
|
||||||
|
val read = page.offset + page.items.size
|
||||||
|
val existing = if (page.offset == 0) emptyList() else categoryPages[category.id]?.items.orEmpty()
|
||||||
|
val items = (existing + page.items).distinctBy(BaseItem::id)
|
||||||
|
val canLoadMore = hasMoreGenreItems(
|
||||||
|
loaded = read,
|
||||||
|
total = page.total,
|
||||||
|
lastPageSize = page.items.size,
|
||||||
|
pageSize = GENRE_PAGE_SIZE,
|
||||||
|
)
|
||||||
|
// Filed under its genre whether or not that genre is the one on screen. A warm
|
||||||
|
// for the category below the selection has no state to write and exists only to
|
||||||
|
// leave this behind; keeping the cache write inside the state guard is what made
|
||||||
|
// it fetch a page and then throw it away.
|
||||||
|
categoryPages[category.id] = CachedCategoryPage(items, read, canLoadMore)
|
||||||
_state.update { current ->
|
_state.update { current ->
|
||||||
if (current.selectedCategoryId != category.id || current.readOffset != page.offset) {
|
if (current.selectedCategoryId != category.id || current.readOffset != page.offset) {
|
||||||
return@update current
|
return@update current
|
||||||
}
|
}
|
||||||
val read = page.offset + page.items.size
|
|
||||||
val items = (current.items + page.items).distinctBy(BaseItem::id)
|
|
||||||
val canLoadMore = hasMoreGenreItems(
|
|
||||||
loaded = read,
|
|
||||||
total = page.total,
|
|
||||||
lastPageSize = page.items.size,
|
|
||||||
pageSize = GENRE_PAGE_SIZE,
|
|
||||||
)
|
|
||||||
categoryPages[category.id] = CachedCategoryPage(items, read, canLoadMore)
|
|
||||||
current.copy(
|
current.copy(
|
||||||
items = items,
|
items = items,
|
||||||
readOffset = read,
|
readOffset = read,
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ enum class GenreCategoryIcon {
|
|||||||
|
|
||||||
const val ALL_MEDIA_CATEGORY_ID = "all"
|
const val ALL_MEDIA_CATEGORY_ID = "all"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mixed shelf: films and series together.
|
||||||
|
*
|
||||||
|
* The Movies and TV Series pages name a type, so no category of theirs can mix the two
|
||||||
|
* grids. The Genres destination is deliberately the other thing — a household browses
|
||||||
|
* "Comedy", not "comedy films" — and passes this instead.
|
||||||
|
*/
|
||||||
|
const val ALL_MEDIA_ITEM_TYPE = ""
|
||||||
|
|
||||||
private val allMediaCategory = GenreCategory(
|
private val allMediaCategory = GenreCategory(
|
||||||
id = ALL_MEDIA_CATEGORY_ID,
|
id = ALL_MEDIA_CATEGORY_ID,
|
||||||
label = "All",
|
label = "All",
|
||||||
@@ -87,8 +96,9 @@ private val realityCategory = GenreCategory(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun genreCategories(itemType: String): List<GenreCategory> =
|
fun genreCategories(itemType: String): List<GenreCategory> =
|
||||||
if (itemType.equals("Series", ignoreCase = true)) coreGenreCategories + realityCategory
|
if (itemType.equals("Movie", ignoreCase = true)) coreGenreCategories
|
||||||
else coreGenreCategories
|
// Reality is a television shelf, so it is offered wherever series can appear.
|
||||||
|
else coreGenreCategories + realityCategory
|
||||||
|
|
||||||
fun genreCategoryTabs(itemType: String): List<GenreCategory> =
|
fun genreCategoryTabs(itemType: String): List<GenreCategory> =
|
||||||
listOf(allMediaCategory.copy(label = allMediaLabel(itemType))) + genreCategories(itemType)
|
listOf(allMediaCategory.copy(label = allMediaLabel(itemType))) + genreCategories(itemType)
|
||||||
@@ -96,8 +106,35 @@ fun genreCategoryTabs(itemType: String): List<GenreCategory> =
|
|||||||
fun genreCategory(itemType: String, id: String?): GenreCategory =
|
fun genreCategory(itemType: String, id: String?): GenreCategory =
|
||||||
genreCategoryTabs(itemType).firstOrNull { it.id == id } ?: genreCategoryTabs(itemType).first()
|
genreCategoryTabs(itemType).firstOrNull { it.id == id } ?: genreCategoryTabs(itemType).first()
|
||||||
|
|
||||||
fun allMediaLabel(itemType: String): String =
|
fun allMediaLabel(itemType: String): String = when {
|
||||||
if (itemType.equals("Series", ignoreCase = true)) "All TV shows" else "All Movies"
|
itemType.equals("Series", ignoreCase = true) -> "All TV shows"
|
||||||
|
itemType.equals("Movie", ignoreCase = true) -> "All Movies"
|
||||||
|
else -> "All genres"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What this shelf is made of, for the heading under a genre's name. */
|
||||||
|
fun genreMediaLabel(itemType: String): String = when {
|
||||||
|
itemType.equals("Series", ignoreCase = true) -> "TV shows"
|
||||||
|
itemType.equals("Movie", ignoreCase = true) -> "Movies"
|
||||||
|
else -> "Movies & TV shows"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The categories either side of the selected one, which is what the shelf warms.
|
||||||
|
*
|
||||||
|
* Vertical travel through the rail is the only way a genre is reached, so the neighbours
|
||||||
|
* are the only two candidates worth the request — and the pair is what makes travelling
|
||||||
|
* through the rail feel like it has already loaded. Deliberately not the whole list: a
|
||||||
|
* household's Emby would answer sixteen genre requests for the fifteen nobody visited.
|
||||||
|
*/
|
||||||
|
fun adjacentCategoryIds(categories: List<GenreCategory>, selectedId: String?): List<String> {
|
||||||
|
val index = categories.indexOfFirst { it.id == selectedId }
|
||||||
|
if (index < 0) return emptyList()
|
||||||
|
return listOfNotNull(
|
||||||
|
categories.getOrNull(index - 1)?.id,
|
||||||
|
categories.getOrNull(index + 1)?.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun BaseItem.withFavourite(favourite: Boolean): BaseItem = copy(
|
fun BaseItem.withFavourite(favourite: Boolean): BaseItem = copy(
|
||||||
userData = (userData ?: UserItemData()).copy(isFavorite = favourite),
|
userData = (userData ?: UserItemData()).copy(isFavorite = favourite),
|
||||||
|
|||||||
@@ -580,6 +580,12 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
?: intent.getBooleanExtra(EXTRA_SKIP_INTRO, false)
|
?: intent.getBooleanExtra(EXTRA_SKIP_INTRO, false)
|
||||||
endCreditsAvailable = savedInstanceState?.getBoolean(STATE_END_CREDITS)
|
endCreditsAvailable = savedInstanceState?.getBoolean(STATE_END_CREDITS)
|
||||||
?: intent.getBooleanExtra(EXTRA_END_CREDITS, false)
|
?: intent.getBooleanExtra(EXTRA_END_CREDITS, false)
|
||||||
|
// Carried across the recreate a Magic press causes, so the button keeps its memory
|
||||||
|
// of what it has already put in front of this viewer.
|
||||||
|
savedInstanceState?.getStringArrayList(STATE_MAGIC_OFFERED)?.let {
|
||||||
|
magicOffered.clear()
|
||||||
|
magicOffered.addAll(it)
|
||||||
|
}
|
||||||
Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")
|
Log.i(PLAYBACK_LOG_TAG, "event=subtitle_configs item=${itemId.orEmpty()} count=${subtitles.size}")
|
||||||
|
|
||||||
setContentView(R.layout.activity_player)
|
setContentView(R.layout.activity_player)
|
||||||
@@ -862,7 +868,13 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val restoredId = itemId?.takeIf(String::isNotBlank).takeIf { savedInstanceState != null }
|
// Only a genuine recreation of *this* programme can have left a stop worker behind
|
||||||
|
// to cancel. A relaunch for a new intent — a Magic press — also arrives with a
|
||||||
|
// bundle, but its item came off the intent, and waiting on WorkManager for a
|
||||||
|
// session that was never enqueued is delay in front of the first frame.
|
||||||
|
val restoredId = itemId
|
||||||
|
?.takeIf(String::isNotBlank)
|
||||||
|
?.takeIf { it == savedInstanceState?.getString(STATE_ITEM_ID) }
|
||||||
if (restoredId != null) {
|
if (restoredId != null) {
|
||||||
showPlaybackLoading()
|
showPlaybackLoading()
|
||||||
val restoredSession = playbackSession(restoredId)
|
val restoredSession = playbackSession(restoredId)
|
||||||
@@ -3112,6 +3124,12 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
// A film is a new subject, not the next step of this one, so it goes through the
|
// A film is a new subject, not the next step of this one, so it goes through the
|
||||||
// ordinary launch rather than through playNext: a fresh player, a fresh pre-roll
|
// ordinary launch rather than through playNext: a fresh player, a fresh pre-roll
|
||||||
// decision and a fresh session, exactly as pressing Play on its detail page gives.
|
// decision and a fresh session, exactly as pressing Play on its detail page gives.
|
||||||
|
//
|
||||||
|
// Deliberately no finish() after it. This activity is singleTask, so the launch is
|
||||||
|
// delivered to *this* instance as onNewIntent, which recreates it against the new
|
||||||
|
// programme — and finishing here raced that recreate and won, which is why a press
|
||||||
|
// named a film in a toast and then dropped the viewer back on the launcher with
|
||||||
|
// nothing playing.
|
||||||
startActivity(
|
startActivity(
|
||||||
intent(
|
intent(
|
||||||
this@PlayerActivity,
|
this@PlayerActivity,
|
||||||
@@ -3126,7 +3144,6 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
backdropUrl = pick.backdropUrl,
|
backdropUrl = pick.backdropUrl,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4980,6 +4997,12 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
outState.putString(STATE_TRAILER_REQUEST, playerJson.encodeToString(it))
|
outState.putString(STATE_TRAILER_REQUEST, playerJson.encodeToString(it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Outside the block above deliberately: a Magic press *is* a relaunch for a new
|
||||||
|
// intent, and what this button has already offered is exactly what the incoming
|
||||||
|
// instance needs so a second press is a second film.
|
||||||
|
if (magicOffered.isNotEmpty()) {
|
||||||
|
outState.putStringArrayList(STATE_MAGIC_OFFERED, ArrayList(magicOffered))
|
||||||
|
}
|
||||||
super.onSaveInstanceState(outState)
|
super.onSaveInstanceState(outState)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5315,6 +5338,7 @@ class PlayerActivity : ComponentActivity() {
|
|||||||
private const val STATE_EPISODE_CODE = "state_episode_code"
|
private const val STATE_EPISODE_CODE = "state_episode_code"
|
||||||
private const val STATE_RUNTIME_MS = "state_runtime_ms"
|
private const val STATE_RUNTIME_MS = "state_runtime_ms"
|
||||||
private const val STATE_TRAILER_REQUEST = "state_trailer_request"
|
private const val STATE_TRAILER_REQUEST = "state_trailer_request"
|
||||||
|
private const val STATE_MAGIC_OFFERED = "state_magic_offered"
|
||||||
private const val PLAYER_PREFERENCES = "player_preferences"
|
private const val PLAYER_PREFERENCES = "player_preferences"
|
||||||
private const val SUBTITLE_SIZE_KEY = "subtitle_size"
|
private const val SUBTITLE_SIZE_KEY = "subtitle_size"
|
||||||
private const val PICTURE_MODE_KEY = "picture_mode"
|
private const val PICTURE_MODE_KEY = "picture_mode"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1,114 @@
|
|||||||
|
package com.ponzischeme89.memby.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyPalette
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||||
|
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the navigation rail to PNGs under `build/screenshots/left-rail/`.
|
||||||
|
*
|
||||||
|
* ```powershell
|
||||||
|
* .\gradlew.bat :app:testDebugUnitTest --tests "*LeftRailScreenshotTest"
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* It exists for the mark at the top of it. Memby's icon is drawn at 30dp on a near-black
|
||||||
|
* surface, which is the size at which a tile-and-letterform either reads or turns to mush,
|
||||||
|
* and no unit test can answer that.
|
||||||
|
*
|
||||||
|
* The themed capture is the other half of the same judgement. The icon deliberately keeps
|
||||||
|
* its own green while everything around it repaints, so this is where that is checked to
|
||||||
|
* still look intentional rather than like a mark the theme failed to reach.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||||
|
class LeftRailScreenshotTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val compose = createComposeRule()
|
||||||
|
|
||||||
|
/** How the rail is drawn for most of a session: collapsed, Home selected. */
|
||||||
|
@Test
|
||||||
|
fun `collapsed rail`() {
|
||||||
|
capture("left-rail-collapsed", expanded = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mark beside the wordmark, which is the one place the two are read together. */
|
||||||
|
@Test
|
||||||
|
fun `expanded rail`() {
|
||||||
|
capture("left-rail-expanded", expanded = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every destination a fully capable household carries, so nothing is cut off the end. */
|
||||||
|
@Test
|
||||||
|
fun `expanded rail with every destination`() {
|
||||||
|
capture(
|
||||||
|
"left-rail-expanded-full",
|
||||||
|
expanded = true,
|
||||||
|
selected = BrowseDestination.MOVIES,
|
||||||
|
calendarEnabled = true,
|
||||||
|
alertCount = 3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mark takes its colour from the palette; a theme that could not reach it shows here. */
|
||||||
|
@Test
|
||||||
|
fun `under a themed palette`() {
|
||||||
|
applyMembyPalette(
|
||||||
|
MembyPalette(
|
||||||
|
surface = Color(0xFF120A16),
|
||||||
|
surfaceRaised = Color(0xFF1D1224),
|
||||||
|
accent = Color(0xFFE0803A),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
capture("left-rail-themed", expanded = true)
|
||||||
|
} finally {
|
||||||
|
applyMembyPalette(MembyPalette())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun capture(
|
||||||
|
name: String,
|
||||||
|
expanded: Boolean,
|
||||||
|
selected: BrowseDestination = BrowseDestination.HOME,
|
||||||
|
calendarEnabled: Boolean = false,
|
||||||
|
alertCount: Int = 0,
|
||||||
|
) {
|
||||||
|
compose.setContent {
|
||||||
|
Box(Modifier.fillMaxSize().background(MembySurface)) {
|
||||||
|
TvNavigationRail(
|
||||||
|
selected = selected,
|
||||||
|
expanded = expanded,
|
||||||
|
navigationFocusRequester = remembered(),
|
||||||
|
onRailFocusChanged = {},
|
||||||
|
onDestinationSelected = {},
|
||||||
|
alertCount = alertCount,
|
||||||
|
activeUsername = "Matt",
|
||||||
|
calendarEnabled = calendarEnabled,
|
||||||
|
genresEnabled = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onRoot().captureRoboImage("build/screenshots/left-rail/$name.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
@androidx.compose.runtime.Composable
|
||||||
|
private fun remembered(): FocusRequester =
|
||||||
|
androidx.compose.runtime.remember { FocusRequester() }
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.ponzischeme89.memby.ui
|
|||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class NavigationRailTest {
|
class NavigationRailTest {
|
||||||
@@ -26,4 +27,24 @@ class NavigationRailTest {
|
|||||||
assertEquals(BrowseDestination.entries.size - 1, items.size)
|
assertEquals(BrowseDestination.entries.size - 1, items.size)
|
||||||
assertFalse(navigationRailItems(calendarEnabled = false).contains(BrowseDestination.CALENDAR))
|
assertFalse(navigationRailItems(calendarEnabled = false).contains(BrowseDestination.CALENDAR))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `genres is a rail entry only while the server has the browser on`() {
|
||||||
|
// The stance the calendar takes: a destination with nothing behind it is worse
|
||||||
|
// than one fewer, because it only ever opens an apology.
|
||||||
|
assertFalse(
|
||||||
|
navigationRailItems(calendarEnabled = true, genresEnabled = false)
|
||||||
|
.contains(BrowseDestination.GENRES),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
navigationRailItems(calendarEnabled = false, genresEnabled = true)
|
||||||
|
.contains(BrowseDestination.GENRES),
|
||||||
|
)
|
||||||
|
// Whichever capabilities the household has, the user switcher stays pinned above
|
||||||
|
// Home — nothing added below it may push those two apart.
|
||||||
|
assertEquals(
|
||||||
|
listOf(BrowseDestination.PROFILES, BrowseDestination.HOME),
|
||||||
|
navigationRailItems(calendarEnabled = false, genresEnabled = false).take(2),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,39 @@ class GenreBrowseTest {
|
|||||||
assertEquals("reality", genreCategories("Series").last().id)
|
assertEquals("reality", genreCategories("Series").last().id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the genres destination browses films and shows together`() {
|
||||||
|
// The Movies and TV Series pages name a type so neither grid can cross media
|
||||||
|
// types. The rail's own destination is deliberately the other thing.
|
||||||
|
assertEquals("All genres", allMediaLabel(ALL_MEDIA_ITEM_TYPE))
|
||||||
|
assertEquals("Movies & TV shows", genreMediaLabel(ALL_MEDIA_ITEM_TYPE))
|
||||||
|
assertEquals("Movies", genreMediaLabel("Movie"))
|
||||||
|
assertEquals("TV shows", genreMediaLabel("Series"))
|
||||||
|
// Reality is a television shelf, so the mixed catalogue offers it too.
|
||||||
|
assertEquals("reality", genreCategories(ALL_MEDIA_ITEM_TYPE).last().id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only the genres either side of the selection are warmed`() {
|
||||||
|
val categories = genreCategoryTabs("Movie")
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(categories[0].id, categories[2].id),
|
||||||
|
adjacentCategoryIds(categories, categories[1].id),
|
||||||
|
)
|
||||||
|
// The ends have one neighbour each rather than wrapping: the rail does not, and a
|
||||||
|
// warm for the genre at the far end is one nothing is about to ask for.
|
||||||
|
assertEquals(listOf(categories[1].id), adjacentCategoryIds(categories, categories.first().id))
|
||||||
|
assertEquals(
|
||||||
|
listOf(categories[categories.lastIndex - 1].id),
|
||||||
|
adjacentCategoryIds(categories, categories.last().id),
|
||||||
|
)
|
||||||
|
// A selection nothing recognises warms nothing rather than warming the top of the
|
||||||
|
// list, which is not where the viewer is.
|
||||||
|
assertEquals(emptyList<String>(), adjacentCategoryIds(categories, "not-a-genre"))
|
||||||
|
assertEquals(emptyList<String>(), adjacentCategoryIds(categories, null))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `favourite overrides update a paged card copy immediately`() {
|
fun `favourite overrides update a paged card copy immediately`() {
|
||||||
val item = BaseItem(id = "film", name = "Film", type = "Movie")
|
val item = BaseItem(id = "film", name = "Film", type = "Movie")
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package com.ponzischeme89.memby.ui.genre
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.aspectRatio
|
||||||
|
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.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onRoot
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.takahirom.roborazzi.captureRoboImage
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyPalette
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||||
|
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||||
|
import com.ponzischeme89.memby.ui.theme.applyMembyPalette
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the genre rail beside a stand-in grid to PNGs under
|
||||||
|
* `build/screenshots/genre-rail/`.
|
||||||
|
*
|
||||||
|
* ```powershell
|
||||||
|
* .\gradlew.bat :app:testDebugUnitTest --tests "*GenreRailScreenshotTest"
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The thing worth looking at is the *hierarchy*: three columns — the launcher's rail, the
|
||||||
|
* genre rail, the content — have to read as one screen with a legible order to them, and
|
||||||
|
* the separation between the first two is tonal rather than structural. No unit test can
|
||||||
|
* settle whether a translucent surface and a hairline are enough of a line at three
|
||||||
|
* metres, and getting it wrong in either direction (invisible, or a second black column
|
||||||
|
* that makes the screen look like two applications side by side) looks fine in code.
|
||||||
|
*
|
||||||
|
* The launcher rail is a stand-in of its real 54dp collapsed width rather than the real
|
||||||
|
* component, which would want a service locator; what is being judged is the boundary,
|
||||||
|
* and 54dp of near-black is exactly what sits there.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||||
|
class GenreRailScreenshotTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val compose = createComposeRule()
|
||||||
|
|
||||||
|
/** The remote in the rail: the genre under the thumb is the one fill on the screen. */
|
||||||
|
@Test
|
||||||
|
fun `remote in the genre rail`() {
|
||||||
|
capture("genre-rail-focused", active = "drama", focusForCapture = "drama")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The remote in the grid — the state the focus/active distinction exists for. Drama
|
||||||
|
* has to still say it is the genre on screen without wearing the focus treatment, or
|
||||||
|
* a viewer down in the posters has nothing telling them what they are looking at.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `remote in the grid`() {
|
||||||
|
capture("genre-rail-content-focused", active = "drama", focusForCapture = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The entry state: the whole catalogue, nothing narrowed yet. */
|
||||||
|
@Test
|
||||||
|
fun `all genres`() {
|
||||||
|
capture("genre-rail-all", active = ALL_MEDIA_CATEGORY_ID, focusForCapture = ALL_MEDIA_CATEGORY_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Under a foreign palette. The rail is drawn entirely from the tokens, so a theme that
|
||||||
|
* could not reach its plate or its marker would show here.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `under a themed palette`() {
|
||||||
|
applyMembyPalette(
|
||||||
|
MembyPalette(
|
||||||
|
surface = Color(0xFF120A16),
|
||||||
|
surfaceRaised = Color(0xFF1D1224),
|
||||||
|
accent = Color(0xFFE0803A),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
capture("genre-rail-themed", active = "horror", focusForCapture = "horror")
|
||||||
|
} finally {
|
||||||
|
applyMembyPalette(MembyPalette())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun capture(name: String, active: String, focusForCapture: String?) {
|
||||||
|
val categories = genreCategoryTabs(ALL_MEDIA_ITEM_TYPE)
|
||||||
|
compose.setContent {
|
||||||
|
Row(Modifier.fillMaxSize().background(MembySurface)) {
|
||||||
|
// The launcher's own rail, at its real collapsed footprint.
|
||||||
|
Box(Modifier.width(54.dp).fillMaxSize().background(MembySurface))
|
||||||
|
GenreRail(
|
||||||
|
categories = categories,
|
||||||
|
activeCategoryId = active,
|
||||||
|
navigationFocusRequester = FocusRequester(),
|
||||||
|
activeFocusRequester = FocusRequester(),
|
||||||
|
onCategoryFocused = {},
|
||||||
|
onEnterContent = { false },
|
||||||
|
focusForCapture = focusForCapture,
|
||||||
|
)
|
||||||
|
StandInGrid(genreCategory(ALL_MEDIA_ITEM_TYPE, active).label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onRoot().captureRoboImage("build/screenshots/genre-rail/$name.png")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enough of the pane to judge the heading against the rail beside it. */
|
||||||
|
@Composable
|
||||||
|
private fun StandInGrid(heading: String) {
|
||||||
|
Column(Modifier.fillMaxSize().padding(start = 40.dp, top = 44.dp)) {
|
||||||
|
Text(heading, color = Color.White, fontSize = 34.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Text(
|
||||||
|
genreMediaLabel(ALL_MEDIA_ITEM_TYPE),
|
||||||
|
color = MembyQuietText,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
repeat(2) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||||
|
repeat(5) {
|
||||||
|
Box(
|
||||||
|
Modifier.width(118.dp).aspectRatio(2f / 3f)
|
||||||
|
.clip(RoundedCornerShape(MembyCardCorner))
|
||||||
|
.background(if (it == 0) MembyControlSurface else MembySurfaceRaised),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(22.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# Optional release-builder settings. Copy only the names you need into the deployment
|
|
||||||
# .env file. Signing credentials themselves belong in files under MEMBY_SECRETS_DIR.
|
|
||||||
MEMBY_SECRETS_DIR=
|
|
||||||
MEMBY_SOURCE_REPOSITORY=
|
|
||||||
MEMBY_RELEASE_TAG=
|
|
||||||
MEMBY_RELEASE_NOTES=
|
|
||||||
MEMBY_RELEASE_MANDATORY=
|
|
||||||
MEMBY_SKIP_APP_TESTS=
|
|
||||||
MEMBY_SOURCE_URL=
|
|
||||||
MEMBY_BUILDER_MEMORY_LIMIT=
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
**
|
|
||||||
!Dockerfile
|
|
||||||
!release.sh
|
|
||||||
!controller.go
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
|
||||||
|
|
||||||
FROM golang:1.26-alpine AS controller
|
|
||||||
WORKDIR /src
|
|
||||||
COPY controller.go .
|
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/memby-builder-controller controller.go
|
|
||||||
|
|
||||||
FROM eclipse-temurin:17-jdk-jammy
|
|
||||||
|
|
||||||
ARG ANDROID_COMMAND_LINE_TOOLS_VERSION=15859902
|
|
||||||
ARG ANDROID_COMMAND_LINE_TOOLS_SHA256=4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583
|
|
||||||
ARG ANDROID_PLATFORM=35
|
|
||||||
ARG ANDROID_BUILD_TOOLS=35.0.0
|
|
||||||
|
|
||||||
ENV ANDROID_HOME=/opt/android-sdk \
|
|
||||||
ANDROID_SDK_ROOT=/opt/android-sdk \
|
|
||||||
GRADLE_USER_HOME=/home/memby/.gradle \
|
|
||||||
PATH=/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/opt/android-sdk/build-tools/35.0.0:${PATH}
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|
||||||
--mount=type=cache,target=/var/lib/apt,sharing=locked \
|
|
||||||
apt-get update && \
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|
||||||
ca-certificates curl git unzip && \
|
|
||||||
rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" /tmp/android-tools && \
|
|
||||||
curl --fail --location --show-error --silent \
|
|
||||||
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_COMMAND_LINE_TOOLS_VERSION}_latest.zip" \
|
|
||||||
--output /tmp/android-tools.zip && \
|
|
||||||
echo "${ANDROID_COMMAND_LINE_TOOLS_SHA256} /tmp/android-tools.zip" | sha256sum --check --strict && \
|
|
||||||
unzip -q /tmp/android-tools.zip -d /tmp/android-tools && \
|
|
||||||
mv /tmp/android-tools/cmdline-tools "${ANDROID_HOME}/cmdline-tools/latest" && \
|
|
||||||
yes | sdkmanager --licenses >/dev/null && \
|
|
||||||
sdkmanager \
|
|
||||||
"platform-tools" \
|
|
||||||
"platforms;android-${ANDROID_PLATFORM}" \
|
|
||||||
"build-tools;${ANDROID_BUILD_TOOLS}" && \
|
|
||||||
rm -rf /tmp/android-tools /tmp/android-tools.zip /root/.android
|
|
||||||
|
|
||||||
# Match the distroless gateway's non-root uid so both services can safely use the
|
|
||||||
# memby-releases volume. The signing material remains a read-only Compose secret.
|
|
||||||
RUN groupadd --gid 65532 memby && \
|
|
||||||
useradd --uid 65532 --gid 65532 --create-home --home-dir /home/memby memby && \
|
|
||||||
mkdir -p /work /data/releases "${GRADLE_USER_HOME}" && \
|
|
||||||
chown -R 65532:65532 /work /data/releases /home/memby
|
|
||||||
|
|
||||||
COPY --chmod=0755 release.sh /usr/local/bin/memby-builder
|
|
||||||
COPY --from=controller --chmod=0755 /out/memby-builder-controller /usr/local/bin/memby-builder-controller
|
|
||||||
|
|
||||||
USER 65532:65532
|
|
||||||
WORKDIR /work
|
|
||||||
ENTRYPOINT ["/usr/local/bin/memby-builder"]
|
|
||||||
CMD ["release"]
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
# Memby Docker release builder
|
|
||||||
|
|
||||||
The `memby-builder` Compose service is an isolated Android toolchain. In the normal stack,
|
|
||||||
its small internal controller waits for the **Build latest release** button on Admin
|
|
||||||
Console → App updates. It then fetches the newest semantic `v*.*.*` tag from GitHub,
|
|
||||||
builds that exact source with its Gradle wrapper, signs it with Memby's existing release
|
|
||||||
identity, verifies it, and publishes it through the running gateway. Its port is exposed
|
|
||||||
only to the Compose network; the browser never receives its address or release token.
|
|
||||||
|
|
||||||
The image pins JDK 17, Android command-line tools 15859902, Android platform 35 and build
|
|
||||||
tools 35.0.0. Android SDK packages stay in the image layer; the `memby-gradle-cache` volume
|
|
||||||
persists Gradle distributions and dependencies between releases.
|
|
||||||
|
|
||||||
## One-time NAS setup
|
|
||||||
|
|
||||||
Set `MEMBY_SECRETS_DIR` in the deployment `.env` to an absolute directory that is outside
|
|
||||||
the directory replaced by `deploy-server.ps1`. The standard NAS value is:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
MEMBY_SECRETS_DIR=/share/Docker/Memby-secrets
|
|
||||||
```
|
|
||||||
|
|
||||||
Create that directory with restrictive permissions and place these five files in it:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/share/Docker/Memby-secrets/
|
|
||||||
memby-release.jks
|
|
||||||
memby-keystore-password
|
|
||||||
memby-key-alias
|
|
||||||
memby-key-password
|
|
||||||
memby-release-publish-token
|
|
||||||
```
|
|
||||||
|
|
||||||
- `memby-release.jks` must be the existing Memby release keystore. Do not generate a new
|
|
||||||
key: Android would reject it as an upgrade for every installed television.
|
|
||||||
- The three signing text files contain only their respective existing value, with no
|
|
||||||
`NAME=` prefix.
|
|
||||||
- `memby-release-publish-token` contains the existing gateway release-publish token. Move
|
|
||||||
that value out of `.env`; the gateway and builder now read the same Compose secret.
|
|
||||||
On the first upgraded deployment, `deploy-server.ps1` migrates this value automatically
|
|
||||||
when it is still present in the previous deployed `.env`. It never generates a new one.
|
|
||||||
|
|
||||||
Docker mounts all five files read-only under `/run/secrets`. Their values are never image
|
|
||||||
layers, source files, Compose environment values, Gradle arguments or `docker inspect`
|
|
||||||
output. `deploy-server.ps1` keeps the NAS directory at mode `0700` and its files read-only
|
|
||||||
at `0444`. The file mode is necessary because Compose file secrets are bind mounts and
|
|
||||||
both Memby containers run as non-root; the protected parent directory prevents other NAS
|
|
||||||
accounts from reaching those files.
|
|
||||||
|
|
||||||
## Create a release
|
|
||||||
|
|
||||||
Open Admin Console → App updates and press **Build latest release**. The page shows live
|
|
||||||
status and the retained build output. Leave the tag override blank for the latest GitHub
|
|
||||||
tag, or enter an exact semantic tag for a deliberate recovery build. A required release
|
|
||||||
has an extra confirmation because it blocks older televisions until they update.
|
|
||||||
|
|
||||||
If the console or controller is unavailable, use the command-line fallback from the
|
|
||||||
deployed Memby directory on the NAS:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
docker compose run --rm --build memby-builder release
|
|
||||||
```
|
|
||||||
|
|
||||||
By default the builder queries `https://github.com/ponzischeme89/memby.git` and selects the
|
|
||||||
highest semantic tag. To reproduce a particular tagged release, set an explicit tag for
|
|
||||||
one invocation:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
MEMBY_RELEASE_TAG=v0.2.64 docker compose run --rm --build memby-builder release
|
|
||||||
```
|
|
||||||
|
|
||||||
Optional, non-secret settings are listed with blank values in `builder.env.example`.
|
|
||||||
`MEMBY_RELEASE_MANDATORY=true` makes the published release mandatory; tests run unless
|
|
||||||
`MEMBY_SKIP_APP_TESTS=true` is deliberately set.
|
|
||||||
|
|
||||||
The final files are in the shared `memby-releases` Docker volume under `/data/releases`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/data/releases/memby-<version>.apk
|
|
||||||
/data/releases/memby-<version>.apk.sha256
|
|
||||||
```
|
|
||||||
|
|
||||||
Publishing through the gateway also updates its existing database-backed version,
|
|
||||||
download URL, release notes, size and SHA-256 metadata atomically. Every run checks the
|
|
||||||
application id and version, runs `apksigner verify --verbose --print-certs`, and compares
|
|
||||||
the APK signer digest with the certificate exported from the mounted keystore before the
|
|
||||||
gateway receives the APK.
|
|
||||||
|
|
||||||
The Windows `release.ps1`, `deploy-tv.ps1`, and the existing app-release option in
|
|
||||||
`deploy-server.ps1` remain available for local workflows and continue to use the same
|
|
||||||
signing identity.
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"crypto/subtle"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const maxLogLines = 400
|
|
||||||
|
|
||||||
var tagPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
|
|
||||||
|
|
||||||
type releaseRequest struct {
|
|
||||||
Tag string `json:"tag"`
|
|
||||||
Notes string `json:"notes"`
|
|
||||||
Mandatory bool `json:"mandatory"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type releaseStatus struct {
|
|
||||||
State string `json:"state"`
|
|
||||||
Tag string `json:"tag,omitempty"`
|
|
||||||
Mandatory bool `json:"mandatory"`
|
|
||||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
|
||||||
FinishedAt time.Time `json:"finishedAt,omitempty"`
|
|
||||||
Message string `json:"message,omitempty"`
|
|
||||||
Logs []string `json:"logs"`
|
|
||||||
Fallback string `json:"fallback"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type controller struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
status releaseStatus
|
|
||||||
token []byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
token, err := readSecret("/run/secrets/memby_release_publish_token")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
c := &controller{token: token, status: releaseStatus{
|
|
||||||
State: "idle", Logs: []string{},
|
|
||||||
Fallback: "docker compose run --rm --build memby-builder release",
|
|
||||||
}}
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
|
||||||
mux.Handle("GET /v1/status", c.authorise(http.HandlerFunc(c.handleStatus)))
|
|
||||||
mux.Handle("POST /v1/releases", c.authorise(http.HandlerFunc(c.handleRelease)))
|
|
||||||
server := &http.Server{Addr: ":8090", Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
|
|
||||||
log.Printf("Memby release controller listening on %s", server.Addr)
|
|
||||||
log.Fatal(server.ListenAndServe())
|
|
||||||
}
|
|
||||||
|
|
||||||
func readSecret(path string) ([]byte, error) {
|
|
||||||
value, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("release controller token: %w", err)
|
|
||||||
}
|
|
||||||
value = []byte(strings.TrimSpace(string(value)))
|
|
||||||
if len(value) == 0 {
|
|
||||||
return nil, errors.New("release controller token is empty")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *controller) authorise(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
|
||||||
if subtle.ConstantTimeCompare([]byte(presented), c.token) != 1 {
|
|
||||||
writeError(w, http.StatusUnauthorized, "invalid release token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *controller) handleStatus(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
c.mu.RLock()
|
|
||||||
status := c.status
|
|
||||||
// Start with a non-nil slice so an idle controller emits `[]`, not `null`. The Admin
|
|
||||||
// Console is still defensive for compatibility with already-deployed controllers.
|
|
||||||
status.Logs = append([]string{}, c.status.Logs...)
|
|
||||||
c.mu.RUnlock()
|
|
||||||
writeJSON(w, http.StatusOK, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *controller) handleRelease(w http.ResponseWriter, r *http.Request) {
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
|
||||||
var request releaseRequest
|
|
||||||
decoder := json.NewDecoder(r.Body)
|
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
if err := decoder.Decode(&request); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid release request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request.Tag = strings.TrimSpace(request.Tag)
|
|
||||||
request.Notes = strings.TrimSpace(request.Notes)
|
|
||||||
if request.Tag != "" && !tagPattern.MatchString(request.Tag) {
|
|
||||||
writeError(w, http.StatusBadRequest, "tag must be blank or look like v0.2.64")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(request.Notes) > 4000 {
|
|
||||||
writeError(w, http.StatusBadRequest, "release notes are too long")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.mu.Lock()
|
|
||||||
if c.status.State == "running" {
|
|
||||||
c.mu.Unlock()
|
|
||||||
writeError(w, http.StatusConflict, "a Memby release is already running")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.status = releaseStatus{
|
|
||||||
State: "running", Tag: request.Tag, Mandatory: request.Mandatory,
|
|
||||||
StartedAt: time.Now().UTC(), Message: "Preparing the Android release builder", Logs: []string{},
|
|
||||||
Fallback: "docker compose run --rm --build memby-builder release",
|
|
||||||
}
|
|
||||||
status := c.status
|
|
||||||
c.mu.Unlock()
|
|
||||||
go c.run(request)
|
|
||||||
writeJSON(w, http.StatusAccepted, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *controller) run(request releaseRequest) {
|
|
||||||
command := exec.Command("/usr/local/bin/memby-builder", "release")
|
|
||||||
command.Env = append(os.Environ(),
|
|
||||||
"MEMBY_RELEASE_TAG="+request.Tag,
|
|
||||||
"MEMBY_RELEASE_NOTES="+request.Notes,
|
|
||||||
fmt.Sprintf("MEMBY_RELEASE_MANDATORY=%t", request.Mandatory),
|
|
||||||
)
|
|
||||||
stdout, err := command.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
c.finish(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
command.Stderr = command.Stdout
|
|
||||||
if err := command.Start(); err != nil {
|
|
||||||
c.finish(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
|
||||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
|
||||||
for scanner.Scan() {
|
|
||||||
c.appendLog(scanner.Text())
|
|
||||||
}
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
c.appendLog("Could not read complete build output: " + err.Error())
|
|
||||||
}
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
err = command.Wait()
|
|
||||||
<-done
|
|
||||||
c.finish(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *controller) appendLog(line string) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.status.Logs = append(c.status.Logs, line)
|
|
||||||
if len(c.status.Logs) > maxLogLines {
|
|
||||||
c.status.Logs = append([]string(nil), c.status.Logs[len(c.status.Logs)-maxLogLines:]...)
|
|
||||||
}
|
|
||||||
c.status.Message = line
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *controller) finish(err error) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
c.status.FinishedAt = time.Now().UTC()
|
|
||||||
if err != nil {
|
|
||||||
c.status.State = "failed"
|
|
||||||
c.status.Message = "Release failed: " + err.Error()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.status.State = "succeeded"
|
|
||||||
c.status.Message = "Release built, verified and published"
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, status int, message string) {
|
|
||||||
writeJSON(w, status, map[string]string{"error": message})
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIdleStatusEmitsEmptyLogsArray(t *testing.T) {
|
|
||||||
c := &controller{status: releaseStatus{State: "idle"}}
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
c.handleStatus(recorder, httptest.NewRequest(http.MethodGet, "/v1/status", nil))
|
|
||||||
|
|
||||||
if got := recorder.Body.String(); !strings.Contains(got, `"logs":[]`) {
|
|
||||||
t.Fatalf("idle status must emit an empty logs array: %s", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -Eeuo pipefail
|
|
||||||
|
|
||||||
readonly KEYSTORE_SECRET="${MEMBY_KEYSTORE_SECRET:-/run/secrets/memby_android_keystore}"
|
|
||||||
readonly STORE_PASSWORD_SECRET="${MEMBY_KEYSTORE_PASSWORD_SECRET:-/run/secrets/memby_android_keystore_password}"
|
|
||||||
readonly KEY_ALIAS_SECRET="${MEMBY_KEY_ALIAS_SECRET:-/run/secrets/memby_android_key_alias}"
|
|
||||||
readonly KEY_PASSWORD_SECRET="${MEMBY_KEY_PASSWORD_SECRET:-/run/secrets/memby_android_key_password}"
|
|
||||||
readonly PUBLISH_TOKEN_SECRET="${MEMBY_RELEASE_PUBLISH_TOKEN_SECRET:-/run/secrets/memby_release_publish_token}"
|
|
||||||
readonly SOURCE_REPOSITORY="${MEMBY_SOURCE_REPOSITORY:-https://github.com/ponzischeme89/memby.git}"
|
|
||||||
readonly PUBLISH_URL="${MEMBY_RELEASE_PUBLISH_URL:-http://server:32768/admin/api/release}"
|
|
||||||
readonly EXPECTED_APPLICATION_ID="com.ponzischeme89.memby"
|
|
||||||
|
|
||||||
log() {
|
|
||||||
printf '[memby-builder] %s\n' "$*"
|
|
||||||
}
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
printf '[memby-builder] ERROR: %s\n' "$*" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
require_secret() {
|
|
||||||
local path="$1"
|
|
||||||
local label="$2"
|
|
||||||
[[ -r "$path" ]] || fail "$label secret is missing or unreadable at $path"
|
|
||||||
[[ -s "$path" ]] || fail "$label secret is empty at $path"
|
|
||||||
}
|
|
||||||
|
|
||||||
semantic_latest_tag() {
|
|
||||||
git ls-remote --tags --refs "$SOURCE_REPOSITORY" 'refs/tags/v[0-9]*' |
|
|
||||||
sed -n 's#^[^[:space:]]\+[[:space:]]\+refs/tags/\(v[0-9]\+\.[0-9]\+\.[0-9]\+\)$#\1#p' |
|
|
||||||
sort -V |
|
|
||||||
tail -n 1
|
|
||||||
}
|
|
||||||
|
|
||||||
release_notes() {
|
|
||||||
local source_dir="$1"
|
|
||||||
local version="$2"
|
|
||||||
local notes_file="$3"
|
|
||||||
|
|
||||||
if [[ -n "${MEMBY_RELEASE_NOTES_FILE:-}" ]]; then
|
|
||||||
[[ -r "$MEMBY_RELEASE_NOTES_FILE" ]] || fail "release notes file is unreadable"
|
|
||||||
cp "$MEMBY_RELEASE_NOTES_FILE" "$notes_file"
|
|
||||||
elif [[ -n "${MEMBY_RELEASE_NOTES:-}" ]]; then
|
|
||||||
printf '%s\n' "$MEMBY_RELEASE_NOTES" > "$notes_file"
|
|
||||||
elif [[ -f "$source_dir/CHANGELOG.md" ]]; then
|
|
||||||
awk -v version="$version" '
|
|
||||||
$0 ~ "^## " version "([[:space:]]|$)" { found=1; next }
|
|
||||||
found && /^## / { exit }
|
|
||||||
found && /^- / { sub(/^- /, ""); print }
|
|
||||||
' "$source_dir/CHANGELOG.md" > "$notes_file"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -s "$notes_file" ]]; then
|
|
||||||
printf 'Memby %s release.\n' "$version" > "$notes_file"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
publish_release() {
|
|
||||||
local apk="$1"
|
|
||||||
local version="$2"
|
|
||||||
local sha256="$3"
|
|
||||||
local notes_file="$4"
|
|
||||||
local mandatory="$5"
|
|
||||||
local token
|
|
||||||
token="$(<"$PUBLISH_TOKEN_SECRET")"
|
|
||||||
|
|
||||||
# Feed the authorisation header through curl's stdin configuration. The token is
|
|
||||||
# never present in the container configuration, process arguments or command log.
|
|
||||||
printf 'header = "Authorization: Bearer %s"\n' "$token" |
|
|
||||||
curl --config - --fail-with-body --show-error --silent \
|
|
||||||
--output /dev/null \
|
|
||||||
--request POST \
|
|
||||||
--form "version=$version" \
|
|
||||||
--form "sha256=$sha256" \
|
|
||||||
--form "mandatory=$mandatory" \
|
|
||||||
--form "notes=<$notes_file" \
|
|
||||||
--form "apk=@$apk;type=application/vnd.android.package-archive" \
|
|
||||||
"$PUBLISH_URL"
|
|
||||||
}
|
|
||||||
|
|
||||||
release() {
|
|
||||||
require_secret "$KEYSTORE_SECRET" 'release keystore'
|
|
||||||
require_secret "$STORE_PASSWORD_SECRET" 'keystore password'
|
|
||||||
require_secret "$KEY_ALIAS_SECRET" 'key alias'
|
|
||||||
require_secret "$KEY_PASSWORD_SECRET" 'key password'
|
|
||||||
require_secret "$PUBLISH_TOKEN_SECRET" 'release publish token'
|
|
||||||
|
|
||||||
local tag="${MEMBY_RELEASE_TAG:-}"
|
|
||||||
if [[ -z "$tag" ]]; then
|
|
||||||
log "Reading the latest GitHub tag from $SOURCE_REPOSITORY"
|
|
||||||
tag="$(semantic_latest_tag)"
|
|
||||||
fi
|
|
||||||
[[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]] ||
|
|
||||||
fail "release tag must look like v0.2.64 (found '${tag:-none}')"
|
|
||||||
local version="${BASH_REMATCH[1]}"
|
|
||||||
|
|
||||||
local work_dir
|
|
||||||
work_dir="$(mktemp -d /work/memby-release.XXXXXX)"
|
|
||||||
trap 'rm -rf -- "$work_dir"' RETURN
|
|
||||||
local source_dir="$work_dir/source"
|
|
||||||
local notes_file="$work_dir/notes.txt"
|
|
||||||
local certificate_file="$work_dir/release-certificate.der"
|
|
||||||
|
|
||||||
log "Fetching $tag"
|
|
||||||
git init -q "$source_dir"
|
|
||||||
git -C "$source_dir" remote add origin "$SOURCE_REPOSITORY"
|
|
||||||
git -C "$source_dir" fetch --quiet --depth=1 origin "refs/tags/$tag:refs/tags/$tag"
|
|
||||||
git -C "$source_dir" checkout --quiet --detach "refs/tags/$tag"
|
|
||||||
|
|
||||||
export MEMBY_KEYSTORE="$KEYSTORE_SECRET"
|
|
||||||
export MEMBY_KEYSTORE_PASSWORD_FILE="$STORE_PASSWORD_SECRET"
|
|
||||||
export MEMBY_KEY_ALIAS_FILE="$KEY_ALIAS_SECRET"
|
|
||||||
export MEMBY_KEY_PASSWORD_FILE="$KEY_PASSWORD_SECRET"
|
|
||||||
# A fetched tag may predate *_FILE support in build.gradle.kts. Populate the
|
|
||||||
# established secure environment form as a compatibility bridge; these values are
|
|
||||||
# created inside the one-shot build process and never enter Compose or docker inspect.
|
|
||||||
export MEMBY_KEYSTORE_PASSWORD
|
|
||||||
MEMBY_KEYSTORE_PASSWORD="$(<"$STORE_PASSWORD_SECRET")"
|
|
||||||
export MEMBY_KEY_ALIAS
|
|
||||||
MEMBY_KEY_ALIAS="$(<"$KEY_ALIAS_SECRET")"
|
|
||||||
export MEMBY_KEY_PASSWORD
|
|
||||||
MEMBY_KEY_PASSWORD="$(<"$KEY_PASSWORD_SECRET")"
|
|
||||||
|
|
||||||
local source_url="${MEMBY_SOURCE_URL:-${SOURCE_REPOSITORY%.git}/tree/$tag}"
|
|
||||||
local -a gradle_tasks=()
|
|
||||||
if [[ "${MEMBY_SKIP_APP_TESTS:-false}" != 'true' ]]; then
|
|
||||||
gradle_tasks+=(testDebugUnitTest)
|
|
||||||
fi
|
|
||||||
gradle_tasks+=(assembleRelease)
|
|
||||||
|
|
||||||
log "Building Memby $version with JDK 17 and Android API 35"
|
|
||||||
chmod +x "$source_dir/gradlew"
|
|
||||||
"$source_dir/gradlew" --project-dir "$source_dir" --console=plain --no-daemon \
|
|
||||||
"${gradle_tasks[@]}" \
|
|
||||||
"-Pmemby.versionName=$version" \
|
|
||||||
"-Pmemby.sourceUrl=$source_url"
|
|
||||||
|
|
||||||
local apk="$source_dir/app/build/outputs/apk/release/app-release.apk"
|
|
||||||
[[ -f "$apk" ]] || {
|
|
||||||
[[ ! -f "$source_dir/app/build/outputs/apk/release/app-release-unsigned.apk" ]] ||
|
|
||||||
fail 'Gradle produced an unsigned APK; check the mounted signing secrets'
|
|
||||||
fail "signed release APK was not produced at $apk"
|
|
||||||
}
|
|
||||||
|
|
||||||
local package_line application_id built_version
|
|
||||||
package_line="$(aapt dump badging "$apk" | sed -n '1p')"
|
|
||||||
application_id="$(sed -n "s/.*package: name='\([^']*\)'.*/\1/p" <<<"$package_line")"
|
|
||||||
built_version="$(sed -n "s/.*versionName='\([^']*\)'.*/\1/p" <<<"$package_line")"
|
|
||||||
[[ "$application_id" == "$EXPECTED_APPLICATION_ID" ]] ||
|
|
||||||
fail "APK applicationId is $application_id, expected $EXPECTED_APPLICATION_ID"
|
|
||||||
[[ "$built_version" == "$version" ]] ||
|
|
||||||
fail "APK version is $built_version, expected $version"
|
|
||||||
|
|
||||||
log 'Verifying the APK signature'
|
|
||||||
local verification signer_digest keystore_digest
|
|
||||||
if ! verification="$(apksigner verify --verbose --print-certs "$apk" 2>&1)"; then
|
|
||||||
printf '%s\n' "$verification" >&2
|
|
||||||
fail 'apksigner verification failed'
|
|
||||||
fi
|
|
||||||
printf '%s\n' "$verification"
|
|
||||||
|
|
||||||
local key_alias
|
|
||||||
key_alias="$(<"$KEY_ALIAS_SECRET")"
|
|
||||||
keytool -exportcert \
|
|
||||||
-keystore "$KEYSTORE_SECRET" \
|
|
||||||
-alias "$key_alias" \
|
|
||||||
-storepass:file "$STORE_PASSWORD_SECRET" \
|
|
||||||
-file "$certificate_file" >/dev/null
|
|
||||||
keystore_digest="$(sha256sum "$certificate_file" | awk '{print $1}')"
|
|
||||||
signer_digest="$(sed -n 's/^Signer #1 certificate SHA-256 digest: //p' <<<"$verification" | head -n 1 | tr -d ':')"
|
|
||||||
[[ -n "$signer_digest" && "${signer_digest,,}" == "$keystore_digest" ]] ||
|
|
||||||
fail 'APK signer does not match the mounted Memby release certificate'
|
|
||||||
|
|
||||||
local sha256
|
|
||||||
sha256="$(sha256sum "$apk" | awk '{print $1}')"
|
|
||||||
release_notes "$source_dir" "$version" "$notes_file"
|
|
||||||
local mandatory="${MEMBY_RELEASE_MANDATORY:-false}"
|
|
||||||
[[ "$mandatory" == 'true' || "$mandatory" == 'false' ]] ||
|
|
||||||
fail 'MEMBY_RELEASE_MANDATORY must be true or false'
|
|
||||||
|
|
||||||
log "Publishing Memby $version to the gateway"
|
|
||||||
publish_release "$apk" "$version" "$sha256" "$notes_file" "$mandatory"
|
|
||||||
|
|
||||||
local published_apk="/data/releases/memby-$version.apk"
|
|
||||||
[[ -f "$published_apk" ]] || fail "gateway did not publish $published_apk"
|
|
||||||
[[ "$(sha256sum "$published_apk" | awk '{print $1}')" == "$sha256" ]] ||
|
|
||||||
fail 'published APK checksum does not match the verified build'
|
|
||||||
local checksum_file="/data/releases/memby-$version.apk.sha256"
|
|
||||||
local checksum_temp
|
|
||||||
checksum_temp="$(mktemp "/data/releases/.memby-$version.sha256.XXXXXX")"
|
|
||||||
printf '%s %s\n' "$sha256" "memby-$version.apk" > "$checksum_temp"
|
|
||||||
mv -f "$checksum_temp" "$checksum_file"
|
|
||||||
|
|
||||||
log "Release complete: $published_apk"
|
|
||||||
log "SHA-256: $sha256"
|
|
||||||
log "Signing certificate SHA-256: $keystore_digest"
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-release}" in
|
|
||||||
release)
|
|
||||||
release
|
|
||||||
;;
|
|
||||||
serve)
|
|
||||||
exec /usr/local/bin/memby-builder-controller
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
fail "unknown command '$1' (expected: release or serve)"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
+25
-121
@@ -26,8 +26,7 @@ SSH performs the password prompt directly. The password is never read or stored
|
|||||||
by this script.
|
by this script.
|
||||||
|
|
||||||
This deploys the current local working tree, including uncommitted server changes.
|
This deploys the current local working tree, including uncommitted server changes.
|
||||||
Use -SkipAppRelease -SkipBuilder for an admin/server-only deployment: no APK is built or
|
Use -SkipAppRelease for an admin/server-only deployment: no APK is built or published.
|
||||||
published, and the running Android builder container and image are left untouched.
|
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\deploy-server.ps1
|
.\deploy-server.ps1
|
||||||
@@ -48,7 +47,7 @@ published, and the running Android builder container and image are left untouche
|
|||||||
.\deploy-server.ps1 -EstimateOnly
|
.\deploy-server.ps1 -EstimateOnly
|
||||||
|
|
||||||
.EXAMPLE
|
.EXAMPLE
|
||||||
.\deploy-server.ps1 -SkipAppRelease -SkipBuilder
|
.\deploy-server.ps1 -SkipAppRelease
|
||||||
#>
|
#>
|
||||||
|
|
||||||
#Requires -Version 7.2
|
#Requires -Version 7.2
|
||||||
@@ -88,11 +87,6 @@ param(
|
|||||||
[Parameter()]
|
[Parameter()]
|
||||||
[switch] $SkipAppRelease,
|
[switch] $SkipAppRelease,
|
||||||
|
|
||||||
# Preserve the Android builder container and reuse its image. This skips the SDK
|
|
||||||
# image build and leaves the release controller untouched during an admin/server deploy.
|
|
||||||
[Parameter()]
|
|
||||||
[switch] $SkipBuilder,
|
|
||||||
|
|
||||||
[Parameter()]
|
[Parameter()]
|
||||||
[Alias('m')]
|
[Alias('m')]
|
||||||
[switch] $MandatoryUpdate,
|
[switch] $MandatoryUpdate,
|
||||||
@@ -427,9 +421,6 @@ function Write-Banner {
|
|||||||
if ($quietDeployment) {
|
if ($quietDeployment) {
|
||||||
Write-Styled -Message '│ NOTICE quiet (no advance television announcement)' -Colour Gray
|
Write-Styled -Message '│ NOTICE quiet (no advance television announcement)' -Colour Gray
|
||||||
}
|
}
|
||||||
if ($SkipBuilder) {
|
|
||||||
Write-Styled -Message '│ BUILDER preserve the running Android builder' -Colour Gray
|
|
||||||
}
|
|
||||||
$initialEstimate = ($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Seconds } |
|
$initialEstimate = ($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Seconds } |
|
||||||
Measure-Object -Sum).Sum
|
Measure-Object -Sum).Sum
|
||||||
$sampleCounts = @($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Samples })
|
$sampleCounts = @($script:PhaseOrder | ForEach-Object { (Get-PhaseEstimate -Key $_).Samples })
|
||||||
@@ -630,8 +621,7 @@ function New-DeploymentArchive {
|
|||||||
'--exclude', 'admin-ui/node_modules', '--exclude', 'admin-ui/node_modules/*',
|
'--exclude', 'admin-ui/node_modules', '--exclude', 'admin-ui/node_modules/*',
|
||||||
'--exclude', 'admin-ui/dist', '--exclude', 'admin-ui/dist/*',
|
'--exclude', 'admin-ui/dist', '--exclude', 'admin-ui/dist/*',
|
||||||
'-C', $RepositoryDirectory,
|
'-C', $RepositoryDirectory,
|
||||||
'server', 'admin-ui', 'builder', 'docker-compose.yml', '.env.example',
|
'server', 'admin-ui', 'docker-compose.yml', '.env.example'
|
||||||
'builder.env.example'
|
|
||||||
)
|
)
|
||||||
if ($ReleaseDirectory) {
|
if ($ReleaseDirectory) {
|
||||||
$arguments += @(
|
$arguments += @(
|
||||||
@@ -789,13 +779,8 @@ try {
|
|||||||
(Join-Path $checkoutDirectory 'admin-ui/Dockerfile'),
|
(Join-Path $checkoutDirectory 'admin-ui/Dockerfile'),
|
||||||
(Join-Path $checkoutDirectory 'admin-ui/package.json'),
|
(Join-Path $checkoutDirectory 'admin-ui/package.json'),
|
||||||
(Join-Path $checkoutDirectory 'admin-ui/src'),
|
(Join-Path $checkoutDirectory 'admin-ui/src'),
|
||||||
(Join-Path $checkoutDirectory 'builder'),
|
|
||||||
(Join-Path $checkoutDirectory 'builder/Dockerfile'),
|
|
||||||
(Join-Path $checkoutDirectory 'builder/release.sh'),
|
|
||||||
(Join-Path $checkoutDirectory 'builder/controller.go'),
|
|
||||||
(Join-Path $checkoutDirectory 'docker-compose.yml'),
|
(Join-Path $checkoutDirectory 'docker-compose.yml'),
|
||||||
(Join-Path $checkoutDirectory '.env.example'),
|
(Join-Path $checkoutDirectory '.env.example')
|
||||||
(Join-Path $checkoutDirectory 'builder.env.example')
|
|
||||||
)
|
)
|
||||||
foreach ($requiredPath in $requiredPaths) {
|
foreach ($requiredPath in $requiredPaths) {
|
||||||
if (-not (Test-Path -LiteralPath $requiredPath)) {
|
if (-not (Test-Path -LiteralPath $requiredPath)) {
|
||||||
@@ -804,7 +789,6 @@ try {
|
|||||||
}
|
}
|
||||||
Write-Detail 'server/ build context'
|
Write-Detail 'server/ build context'
|
||||||
Write-Detail 'admin-ui/ build context'
|
Write-Detail 'admin-ui/ build context'
|
||||||
Write-Detail 'builder/ release toolchain'
|
|
||||||
Write-Detail 'docker-compose.yml'
|
Write-Detail 'docker-compose.yml'
|
||||||
Write-Detail '.env.example'
|
Write-Detail '.env.example'
|
||||||
$releaseDirectory = ''
|
$releaseDirectory = ''
|
||||||
@@ -914,7 +898,6 @@ set -eu
|
|||||||
destination='__DESTINATION__'
|
destination='__DESTINATION__'
|
||||||
health_timeout=__HEALTH_TIMEOUT__
|
health_timeout=__HEALTH_TIMEOUT__
|
||||||
publish_release=__PUBLISH_RELEASE__
|
publish_release=__PUBLISH_RELEASE__
|
||||||
skip_builder=__SKIP_BUILDER__
|
|
||||||
mandatory_update=__MANDATORY_UPDATE__
|
mandatory_update=__MANDATORY_UPDATE__
|
||||||
quiet_deployment=__QUIET_DEPLOYMENT__
|
quiet_deployment=__QUIET_DEPLOYMENT__
|
||||||
colour_output=__COLOUR_OUTPUT__
|
colour_output=__COLOUR_OUTPUT__
|
||||||
@@ -950,14 +933,7 @@ failure() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start_restored_stack() {
|
start_restored_stack() {
|
||||||
if [ "$skip_builder" -eq 1 ]; then
|
docker compose up -d --build --remove-orphans >/dev/null 2>&1
|
||||||
# Restore the previous gateway/admin sources without rebuilding the large
|
|
||||||
# Android SDK image or touching the still-running builder, database and cache.
|
|
||||||
docker compose build server memby-admin >/dev/null 2>&1 &&
|
|
||||||
docker compose up -d --no-build --no-deps memby-admin server >/dev/null 2>&1
|
|
||||||
else
|
|
||||||
docker compose up -d --build --remove-orphans >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rollback() {
|
rollback() {
|
||||||
@@ -976,12 +952,7 @@ rollback() {
|
|||||||
detail "Stopping the incomplete application release"
|
detail "Stopping the incomplete application release"
|
||||||
(
|
(
|
||||||
cd "$destination"
|
cd "$destination"
|
||||||
if [ "$skip_builder" -eq 1 ]; then
|
docker compose down --remove-orphans >/dev/null 2>&1
|
||||||
docker compose stop server memby-admin >/dev/null 2>&1
|
|
||||||
docker compose rm -f server memby-admin >/dev/null 2>&1
|
|
||||||
else
|
|
||||||
docker compose down --remove-orphans >/dev/null 2>&1
|
|
||||||
fi
|
|
||||||
) || true
|
) || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1090,9 +1061,6 @@ test -f "$staging/docker-compose.yml"
|
|||||||
test -f "$staging/server/Dockerfile"
|
test -f "$staging/server/Dockerfile"
|
||||||
test -f "$staging/admin-ui/Dockerfile"
|
test -f "$staging/admin-ui/Dockerfile"
|
||||||
test -f "$staging/admin-ui/package.json"
|
test -f "$staging/admin-ui/package.json"
|
||||||
test -f "$staging/builder/Dockerfile"
|
|
||||||
test -f "$staging/builder/release.sh"
|
|
||||||
test -f "$staging/builder/controller.go"
|
|
||||||
if [ "$publish_release" -eq 1 ]; then
|
if [ "$publish_release" -eq 1 ]; then
|
||||||
test -f "$staging/release/version.txt"
|
test -f "$staging/release/version.txt"
|
||||||
test -f "$staging/release/sha256.txt"
|
test -f "$staging/release/sha256.txt"
|
||||||
@@ -1112,7 +1080,7 @@ if [ -f "$destination/.env" ]; then
|
|||||||
# tell the televisions anything. It is usually the same as the incoming one, but
|
# tell the televisions anything. It is usually the same as the incoming one, but
|
||||||
# reading it from the release being replaced is what makes that not a requirement.
|
# reading it from the release being replaced is what makes that not a requirement.
|
||||||
previous_admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$destination/.env" | head -n 1 | tr -d '\r')
|
previous_admin_token=$(sed -n 's/^MEMBY_ADMIN_TOKEN=//p' "$destination/.env" | head -n 1 | tr -d '\r')
|
||||||
# First Docker-builder deployment only: migrate the established publish token out
|
# First file-backed-secret deployment only: migrate the established publish token out
|
||||||
# of the old environment file rather than making the operator rotate it mid-release.
|
# of the old environment file rather than making the operator rotate it mid-release.
|
||||||
previous_release_token=$(sed -n 's/^MEMBY_RELEASE_PUBLISH_TOKEN=//p' "$destination/.env" | head -n 1 | tr -d '\r')
|
previous_release_token=$(sed -n 's/^MEMBY_RELEASE_PUBLISH_TOKEN=//p' "$destination/.env" | head -n 1 | tr -d '\r')
|
||||||
# Kept beside the new one purely so a bad edit is recoverable by hand.
|
# Kept beside the new one purely so a bad edit is recoverable by hand.
|
||||||
@@ -1168,44 +1136,18 @@ if [ ! -s "$release_token_file" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
missing_builder_secrets=0
|
if [ ! -s "$release_token_file" ]; then
|
||||||
check_required_secret() {
|
failure "Required secret is missing or empty: $release_token_file"
|
||||||
required_secret="$1"
|
detail 'Restore the existing gateway release token at the path above, then rerun this deployment'
|
||||||
if [ ! -s "$required_secret" ]; then
|
|
||||||
failure "Required secret is missing or empty: $required_secret"
|
|
||||||
missing_builder_secrets=1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
check_required_secret "$release_token_file"
|
|
||||||
if [ "$skip_builder" -ne 1 ]; then
|
|
||||||
check_required_secret "$secrets_dir/memby-release.jks"
|
|
||||||
check_required_secret "$secrets_dir/memby-keystore-password"
|
|
||||||
check_required_secret "$secrets_dir/memby-key-alias"
|
|
||||||
check_required_secret "$secrets_dir/memby-key-password"
|
|
||||||
fi
|
|
||||||
if [ "$missing_builder_secrets" -ne 0 ]; then
|
|
||||||
if [ "$skip_builder" -eq 1 ]; then
|
|
||||||
detail 'Restore the existing gateway release token at the path above, then rerun this deployment'
|
|
||||||
else
|
|
||||||
detail 'Copy the existing signing identity and its three values to the paths above, then rerun this deployment'
|
|
||||||
detail 'Never create a new keystore: installed Memby clients can upgrade only from the existing certificate'
|
|
||||||
fi
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Docker Compose file-backed secrets are read-only bind mounts on the NAS. The gateway
|
# Docker Compose file-backed secrets are read-only bind mounts on the NAS. The gateway
|
||||||
# and builder deliberately run as uid 65532, so files created as the SSH user with 0600
|
# deliberately runs as uid 65532, so a file created as the SSH user with 0600 would be
|
||||||
# would be present but unreadable in those containers. The 0700 parent prevents every
|
# present but unreadable in that container. The 0700 parent prevents every other NAS
|
||||||
# other NAS account from traversing to them; 0444 makes only the read-only secret mounts
|
# account from traversing to it; 0444 makes only the read-only secret mount usable by the
|
||||||
# usable by the non-root container processes and also prevents accidental host writes.
|
# non-root container process and also prevents accidental host writes.
|
||||||
chmod 700 "$secrets_dir"
|
chmod 700 "$secrets_dir"
|
||||||
chmod 444 "$release_token_file"
|
chmod 444 "$release_token_file"
|
||||||
if [ "$skip_builder" -ne 1 ]; then
|
|
||||||
chmod 444 \
|
|
||||||
"$secrets_dir/memby-release.jks" \
|
|
||||||
"$secrets_dir/memby-keystore-password" \
|
|
||||||
"$secrets_dir/memby-key-alias" \
|
|
||||||
"$secrets_dir/memby-key-password"
|
|
||||||
fi
|
|
||||||
success 'Required gateway configuration is present'
|
success 'Required gateway configuration is present'
|
||||||
if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then
|
if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then
|
||||||
failure 'POSTGRES_PASSWORD differs from the deployed value'
|
failure 'POSTGRES_PASSWORD differs from the deployed value'
|
||||||
@@ -1256,34 +1198,15 @@ step 'Pulling PostgreSQL and Redis'
|
|||||||
)
|
)
|
||||||
success 'Dependency images are ready'
|
success 'Dependency images are ready'
|
||||||
|
|
||||||
if [ "$skip_builder" -eq 1 ]; then
|
step 'Building the gateway and admin console'
|
||||||
step 'Building the gateway and admin console; reusing the Android builder'
|
|
||||||
else
|
|
||||||
step 'Building the gateway, admin console and Android builder'
|
|
||||||
fi
|
|
||||||
(
|
(
|
||||||
cd "$staging"
|
cd "$staging"
|
||||||
# `up` reuses an existing image when one is present. Build both local contexts here,
|
# `up` reuses an existing image when one is present. Build both local contexts here,
|
||||||
# otherwise a new React/nginx console can be packaged and activated while the NAS
|
# otherwise a new React/nginx console can be packaged and activated while the NAS
|
||||||
# continues to serve the previous console image (and its old route configuration).
|
# continues to serve the previous console image (and its old route configuration).
|
||||||
if [ "$skip_builder" -eq 1 ]; then
|
docker compose build --pull server memby-admin
|
||||||
existing_builder=$(docker compose ps -q memby-builder 2>/dev/null || true)
|
|
||||||
if [ -z "$existing_builder" ] ||
|
|
||||||
[ "$(docker inspect --format '{{.State.Status}}' "$existing_builder" 2>/dev/null || true)" != 'running' ]; then
|
|
||||||
failure 'No running memby-builder container is available to preserve'
|
|
||||||
detail 'Run once without -SkipBuilder to install and start the Android builder'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
docker compose build --pull server memby-admin
|
|
||||||
else
|
|
||||||
docker compose build --pull server memby-admin memby-builder
|
|
||||||
fi
|
|
||||||
)
|
)
|
||||||
if [ "$skip_builder" -eq 1 ]; then
|
success 'Gateway and admin console images built'
|
||||||
success 'Gateway and admin console images built; Android builder container retained'
|
|
||||||
else
|
|
||||||
success 'Gateway, admin console and Android builder images built'
|
|
||||||
fi
|
|
||||||
|
|
||||||
step 'Activating the release'
|
step 'Activating the release'
|
||||||
rm -rf -- "$backup"
|
rm -rf -- "$backup"
|
||||||
@@ -1292,20 +1215,11 @@ if [ -e "$destination" ] || [ -L "$destination" ]; then
|
|||||||
# Compose projects created by older releases may use a different project
|
# Compose projects created by older releases may use a different project
|
||||||
# name. Stop them from their original directory before moving it so their
|
# name. Stop them from their original directory before moving it so their
|
||||||
# published ports (especially 32768) are released for the new stack.
|
# published ports (especially 32768) are released for the new stack.
|
||||||
if [ "$skip_builder" -eq 1 ]; then
|
detail 'Stopping the previous Compose application'
|
||||||
detail 'Stopping only the gateway and admin console; preserving builder, database and cache'
|
(
|
||||||
(
|
cd "$destination"
|
||||||
cd "$destination"
|
docker compose down --remove-orphans
|
||||||
docker compose stop server memby-admin
|
)
|
||||||
docker compose rm -f server memby-admin
|
|
||||||
)
|
|
||||||
else
|
|
||||||
detail 'Stopping the previous Compose application'
|
|
||||||
(
|
|
||||||
cd "$destination"
|
|
||||||
docker compose down --remove-orphans
|
|
||||||
)
|
|
||||||
fi
|
|
||||||
previous_stopped=1
|
previous_stopped=1
|
||||||
success 'Previous Compose application stopped'
|
success 'Previous Compose application stopped'
|
||||||
fi
|
fi
|
||||||
@@ -1318,17 +1232,12 @@ success 'Release activated'
|
|||||||
|
|
||||||
step 'Starting the Compose stack'
|
step 'Starting the Compose stack'
|
||||||
cd "$destination"
|
cd "$destination"
|
||||||
if [ "$skip_builder" -eq 1 ]; then
|
if ! docker compose up -d --no-build --remove-orphans; then
|
||||||
compose_start='docker compose up -d --no-build --no-deps memby-admin server'
|
|
||||||
else
|
|
||||||
compose_start='docker compose up -d --no-build --remove-orphans'
|
|
||||||
fi
|
|
||||||
if ! $compose_start; then
|
|
||||||
failure 'Compose could not start the complete application'
|
failure 'Compose could not start the complete application'
|
||||||
detail 'Container state before rollback:'
|
detail 'Container state before rollback:'
|
||||||
docker compose ps --all || true
|
docker compose ps --all || true
|
||||||
detail 'Gateway and builder logs before rollback:'
|
detail 'Gateway logs before rollback:'
|
||||||
docker compose logs --no-color --tail 100 server memby-builder || true
|
docker compose logs --no-color --tail 100 server || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
success 'Compose start command completed'
|
success 'Compose start command completed'
|
||||||
@@ -1338,7 +1247,6 @@ wait_for_service postgres
|
|||||||
wait_for_service redis
|
wait_for_service redis
|
||||||
wait_for_service memby-admin
|
wait_for_service memby-admin
|
||||||
wait_for_service server
|
wait_for_service server
|
||||||
wait_for_service memby-builder
|
|
||||||
|
|
||||||
published_address=$(docker compose port server 32768 | head -n 1)
|
published_address=$(docker compose port server 32768 | head -n 1)
|
||||||
actual_port=${published_address##*:}
|
actual_port=${published_address##*:}
|
||||||
@@ -1418,10 +1326,6 @@ success 'Memby gateway: https://mserver.sublogue.com'
|
|||||||
'__PUBLISH_RELEASE__',
|
'__PUBLISH_RELEASE__',
|
||||||
$(if ($SkipAppRelease) { '0' } else { '1' })
|
$(if ($SkipAppRelease) { '0' } else { '1' })
|
||||||
)
|
)
|
||||||
$remoteCommand = $remoteCommand.Replace(
|
|
||||||
'__SKIP_BUILDER__',
|
|
||||||
$(if ($SkipBuilder) { '1' } else { '0' })
|
|
||||||
)
|
|
||||||
$remoteCommand = $remoteCommand.Replace(
|
$remoteCommand = $remoteCommand.Replace(
|
||||||
'__MANDATORY_UPDATE__',
|
'__MANDATORY_UPDATE__',
|
||||||
$(if ($mandatoryRelease) { '1' } else { '0' })
|
$(if ($mandatoryRelease) { '1' } else { '0' })
|
||||||
|
|||||||
@@ -57,9 +57,6 @@ services:
|
|||||||
# keeps the release credential out of `docker inspect` while preserving the
|
# keeps the release credential out of `docker inspect` while preserving the
|
||||||
# existing CI/backend publish API.
|
# existing CI/backend publish API.
|
||||||
MEMBY_RELEASE_PUBLISH_TOKEN_FILE: "/run/secrets/memby_release_publish_token"
|
MEMBY_RELEASE_PUBLISH_TOKEN_FILE: "/run/secrets/memby_release_publish_token"
|
||||||
# The gateway relays authenticated Admin Console actions to this private service.
|
|
||||||
# The builder port is not published and the signing secrets never reach the browser.
|
|
||||||
MEMBY_RELEASE_BUILDER_URL: "http://memby-builder:8090"
|
|
||||||
# Hourly incremental import: enough for episodes landing through the day, and
|
# Hourly incremental import: enough for episodes landing through the day, and
|
||||||
# films appearing weekly ride along.
|
# films appearing weekly ride along.
|
||||||
MEMBY_SYNC_INTERVAL: "${MEMBY_SYNC_INTERVAL:-1h}"
|
MEMBY_SYNC_INTERVAL: "${MEMBY_SYNC_INTERVAL:-1h}"
|
||||||
@@ -118,51 +115,6 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
# An isolated Android toolchain. Its small controller waits for an authenticated
|
|
||||||
# Admin Console request; the same image remains directly runnable as the CLI fallback.
|
|
||||||
# It fetches the newest semantic GitHub tag,
|
|
||||||
# builds with the repository's Gradle wrapper, verifies the existing signing
|
|
||||||
# certificate, and publishes through the gateway's atomic release endpoint.
|
|
||||||
memby-builder:
|
|
||||||
build:
|
|
||||||
context: ./builder
|
|
||||||
args:
|
|
||||||
ANDROID_COMMAND_LINE_TOOLS_VERSION: "15859902"
|
|
||||||
ANDROID_COMMAND_LINE_TOOLS_SHA256: "4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583"
|
|
||||||
ANDROID_PLATFORM: "35"
|
|
||||||
ANDROID_BUILD_TOOLS: "35.0.0"
|
|
||||||
environment:
|
|
||||||
MEMBY_SOURCE_REPOSITORY: "${MEMBY_SOURCE_REPOSITORY:-https://github.com/ponzischeme89/memby.git}"
|
|
||||||
MEMBY_RELEASE_TAG: "${MEMBY_RELEASE_TAG:-}"
|
|
||||||
MEMBY_RELEASE_NOTES: "${MEMBY_RELEASE_NOTES:-}"
|
|
||||||
MEMBY_RELEASE_MANDATORY: "${MEMBY_RELEASE_MANDATORY:-false}"
|
|
||||||
MEMBY_SKIP_APP_TESTS: "${MEMBY_SKIP_APP_TESTS:-false}"
|
|
||||||
MEMBY_SOURCE_URL: "${MEMBY_SOURCE_URL:-}"
|
|
||||||
MEMBY_RELEASE_PUBLISH_URL: "http://server:32768/admin/api/release"
|
|
||||||
command: ["serve"]
|
|
||||||
restart: unless-stopped
|
|
||||||
expose:
|
|
||||||
- "8090"
|
|
||||||
volumes:
|
|
||||||
- memby-gradle-cache:/home/memby/.gradle
|
|
||||||
- memby-releases:/data/releases
|
|
||||||
secrets:
|
|
||||||
- memby_android_keystore
|
|
||||||
- memby_android_keystore_password
|
|
||||||
- memby_android_key_alias
|
|
||||||
- memby_android_key_password
|
|
||||||
- memby_release_publish_token
|
|
||||||
depends_on:
|
|
||||||
server:
|
|
||||||
condition: service_healthy
|
|
||||||
mem_limit: "${MEMBY_BUILDER_MEMORY_LIMIT:-4g}"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:8090/healthz"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 5s
|
|
||||||
|
|
||||||
# The operations console: a React application built at image time and served by nginx.
|
# The operations console: a React application built at image time and served by nginx.
|
||||||
#
|
#
|
||||||
# Deliberately not published. The household's reverse proxy sends one hostname to the
|
# Deliberately not published. The household's reverse proxy sends one hostname to the
|
||||||
@@ -218,16 +170,7 @@ volumes:
|
|||||||
memby-postgres:
|
memby-postgres:
|
||||||
memby-releases:
|
memby-releases:
|
||||||
memby-logs:
|
memby-logs:
|
||||||
memby-gradle-cache:
|
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
memby_android_keystore:
|
|
||||||
file: "${MEMBY_SECRETS_DIR:-./secrets}/memby-release.jks"
|
|
||||||
memby_android_keystore_password:
|
|
||||||
file: "${MEMBY_SECRETS_DIR:-./secrets}/memby-keystore-password"
|
|
||||||
memby_android_key_alias:
|
|
||||||
file: "${MEMBY_SECRETS_DIR:-./secrets}/memby-key-alias"
|
|
||||||
memby_android_key_password:
|
|
||||||
file: "${MEMBY_SECRETS_DIR:-./secrets}/memby-key-password"
|
|
||||||
memby_release_publish_token:
|
memby_release_publish_token:
|
||||||
file: "${MEMBY_SECRETS_DIR:-./secrets}/memby-release-publish-token"
|
file: "${MEMBY_SECRETS_DIR:-./secrets}/memby-release-publish-token"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -60,8 +60,6 @@ func (s *Server) adminRoutes() http.Handler {
|
|||||||
mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime))
|
mux.Handle("POST /admin/api/quiet-time", s.adminAuth(s.handleAdminQuietTime))
|
||||||
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
||||||
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
|
||||||
mux.Handle("GET /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStatus))
|
|
||||||
mux.Handle("POST /admin/api/release-builder", s.adminAuth(s.handleAdminReleaseBuilderStart))
|
|
||||||
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
|
||||||
mux.Handle("GET /admin/api/media-reports", s.adminAuth(s.handleAdminMediaReports))
|
mux.Handle("GET /admin/api/media-reports", s.adminAuth(s.handleAdminMediaReports))
|
||||||
mux.Handle("POST /admin/api/media-reports/{id}/status", s.adminAuth(s.handleAdminMediaReportStatus))
|
mux.Handle("POST /admin/api/media-reports/{id}/status", s.adminAuth(s.handleAdminMediaReportStatus))
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func (s *Server) handleBrowseItems(
|
|||||||
limit := queryInt(r, "limit", genrePageSize, genrePageMax)
|
limit := queryInt(r, "limit", genrePageSize, genrePageMax)
|
||||||
offset := queryOffset(r, "offset")
|
offset := queryOffset(r, "offset")
|
||||||
itemType, ok := genreItemType(r.URL.Query().Get("type"))
|
itemType, ok := genreItemType(r.URL.Query().Get("type"))
|
||||||
if !ok || (genre == "" && itemType == "Movie,Series") {
|
if !ok {
|
||||||
writeError(w, http.StatusBadRequest, "type must be Movie or Series")
|
writeError(w, http.StatusBadRequest, "type must be Movie or Series")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -145,8 +145,15 @@ func (s *Server) handleBrowseItems(
|
|||||||
writeRaw(w, http.StatusOK, body)
|
writeRaw(w, http.StatusOK, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// genreItemType keeps the old mixed search as the default for Search, while the Movies
|
// genreItemType keeps the mixed shelf as the default, which is what the Search chips and
|
||||||
// and TV Series destinations can ask for a shelf that never crosses media types.
|
// the Genres destination ask for, while the Movies and TV Series destinations name a type
|
||||||
|
// and get a shelf that never crosses media types.
|
||||||
|
//
|
||||||
|
// The unfiltered browse used to refuse the mixed type, on the reasoning that a whole
|
||||||
|
// library with no genre and no media type is not a shelf anybody asked for. The Genres
|
||||||
|
// destination is exactly that request — its "All genres" entry is the catalogue itself —
|
||||||
|
// and refusing it here only made the one entry at the top of that rail the one entry that
|
||||||
|
// could not answer.
|
||||||
func genreItemType(value string) (string, bool) {
|
func genreItemType(value string) (string, bool) {
|
||||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
case "":
|
case "":
|
||||||
|
|||||||
@@ -46,21 +46,43 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
writeError(w, http.StatusBadRequest, "item id is required")
|
writeError(w, http.StatusBadRequest, "item id is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Version the entry when the detail contract grows so older cached payloads cannot
|
if raw, err := s.cache.Get(ctx, itemDetailKey(sess.EmbyUserID, itemID)); err == nil {
|
||||||
// hide newly requested fields such as People or the stored ratings.
|
|
||||||
key := cache.UserKey(sess.EmbyUserID, "item:v6:"+itemID)
|
|
||||||
|
|
||||||
if raw, err := s.cache.Get(ctx, key); err == nil {
|
|
||||||
w.Header().Set("X-Memby-Cache", "hit")
|
w.Header().Set("X-Memby-Cache", "hit")
|
||||||
writeRaw(w, http.StatusOK, raw)
|
writeRaw(w, http.StatusOK, raw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
item, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsDetail)
|
item, err := s.detailItem(ctx, sess, itemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeUpstreamError(ctx, w, err, "could not load the item")
|
s.writeUpstreamError(ctx, w, err, "could not load the item")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
w.Header().Set("X-Memby-Cache", "miss")
|
||||||
|
writeRaw(w, http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemDetailKey is versioned so that when the detail contract grows, older cached payloads
|
||||||
|
// cannot hide newly requested fields such as People or the stored ratings.
|
||||||
|
func itemDetailKey(userID, itemID string) string {
|
||||||
|
return cache.UserKey(userID, "item:v6:"+itemID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailItem is the full record for one item, decorated and kept.
|
||||||
|
//
|
||||||
|
// It is shared rather than private to the item route because a Magic press hands back a
|
||||||
|
// title the television is about to open a detail page for — and before this, that press
|
||||||
|
// paid its own uncached Emby lookup and then the page paid a second one a moment later.
|
||||||
|
func (s *Server) detailItem(
|
||||||
|
ctx context.Context, sess store.Session, itemID string,
|
||||||
|
) (json.RawMessage, error) {
|
||||||
|
key := itemDetailKey(sess.EmbyUserID, itemID)
|
||||||
|
if raw, err := s.cache.Get(ctx, key); err == nil {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
item, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsDetail)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// A detail page can then draw its ratings with the rest of the hero rather than
|
// A detail page can then draw its ratings with the rest of the hero rather than
|
||||||
// after a second request. Anything not yet stored still arrives on /ratings.
|
// after a second request. Anything not yet stored still arrives on /ratings.
|
||||||
decorated := []json.RawMessage{item}
|
decorated := []json.RawMessage{item}
|
||||||
@@ -69,8 +91,7 @@ func (s *Server) handleItem(w http.ResponseWriter, r *http.Request, sess store.S
|
|||||||
if err := s.cache.Set(ctx, key, item, s.cfg.ItemTTL); err != nil {
|
if err := s.cache.Set(ctx, key, item, s.cfg.ItemTTL); err != nil {
|
||||||
s.loggerFor(ctx).Warn("item cache write failed", "error", err)
|
s.loggerFor(ctx).Warn("item cache write failed", "error", err)
|
||||||
}
|
}
|
||||||
w.Header().Set("X-Memby-Cache", "miss")
|
return item, nil
|
||||||
writeRaw(w, http.StatusOK, item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSeasonFinale verifies an episode against Sonarr's complete season, including
|
// handleSeasonFinale verifies an episode against Sonarr's complete season, including
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||||
"github.com/ponzischeme89/memby/server/internal/store"
|
"github.com/ponzischeme89/memby/server/internal/store"
|
||||||
)
|
)
|
||||||
@@ -53,12 +56,24 @@ func (s *Server) handleMagic(w http.ResponseWriter, r *http.Request, sess store.
|
|||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
}
|
}
|
||||||
|
|
||||||
selection, ok := s.recommender.MagicPick(ctx, credentials(sess), recommend.MagicOptions{
|
pool, cached := s.magicPool(ctx, sess)
|
||||||
|
selection, ok := recommend.ChooseMagic(pool, recommend.MagicOptions{
|
||||||
ExcludeIDs: req.ExcludeIDs,
|
ExcludeIDs: req.ExcludeIDs,
|
||||||
AvailableMinutes: req.AvailableMinutes,
|
AvailableMinutes: req.AvailableMinutes,
|
||||||
// The one non-deterministic thing about the feature, named in one place.
|
// The one non-deterministic thing about the feature, named in one place.
|
||||||
Roll: rand.Float64(),
|
Roll: rand.Float64(),
|
||||||
})
|
})
|
||||||
|
if !ok && cached {
|
||||||
|
// Everything the kept pool held has already been offered. That is a pool that has
|
||||||
|
// run its course rather than a household with nothing left, so it is rebuilt once
|
||||||
|
// before the button is allowed to say no.
|
||||||
|
pool = s.rebuildMagicPool(ctx, sess)
|
||||||
|
selection, ok = recommend.ChooseMagic(pool, recommend.MagicOptions{
|
||||||
|
ExcludeIDs: req.ExcludeIDs,
|
||||||
|
AvailableMinutes: req.AvailableMinutes,
|
||||||
|
Roll: rand.Float64(),
|
||||||
|
})
|
||||||
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
// A household that has run out of unseen library is not an error, and the television
|
// A household that has run out of unseen library is not an error, and the television
|
||||||
// says so quietly rather than showing a failure over somebody's film.
|
// says so quietly rather than showing a failure over somebody's film.
|
||||||
@@ -67,24 +82,54 @@ func (s *Server) handleMagic(w http.ResponseWriter, r *http.Request, sess store.
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
item, err := s.emby.Item(ctx, credentials(sess), selection.Item.ID, fieldsDetail)
|
item, err := s.detailItem(ctx, sess, selection.ItemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeUpstreamError(ctx, w, err, "could not load the suggestion")
|
s.writeUpstreamError(ctx, w, err, "could not load the suggestion")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
decorated := []json.RawMessage{item}
|
|
||||||
s.decorateItemRatings(ctx, decorated)
|
|
||||||
|
|
||||||
s.loggerFor(ctx).Info("magic picked",
|
s.loggerFor(ctx).Info("magic picked",
|
||||||
"item", selection.Item.ID,
|
"item", selection.ItemID,
|
||||||
"title", selection.Item.Name,
|
"title", selection.Title,
|
||||||
"score", selection.Score,
|
"score", selection.Score,
|
||||||
"pool", selection.PoolSize,
|
"pool", selection.PoolSize,
|
||||||
"signals", strings.Join(selection.Signals, ","),
|
"signals", strings.Join(selection.Signals, ","),
|
||||||
)
|
)
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, magicResponse{
|
writeJSON(w, http.StatusOK, magicResponse{
|
||||||
Item: decorated[0],
|
Item: item,
|
||||||
Reasons: selection.Reasons,
|
Reasons: selection.Reasons,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// magicPool returns the kept pool, and whether it came from the cache.
|
||||||
|
//
|
||||||
|
// The press is made with the film paused behind a loading panel, so what happens on it
|
||||||
|
// matters: building a pool is two complete reads of this viewer's Emby history plus a
|
||||||
|
// catalogue query, and none of that answer changes between one press and the next. Keeping
|
||||||
|
// it turns every press after the first into arithmetic over a few dozen numbers.
|
||||||
|
func (s *Server) magicPool(ctx context.Context, sess store.Session) ([]recommend.MagicCandidate, bool) {
|
||||||
|
if raw, err := s.cache.Get(ctx, cache.MagicPoolKey(sess.EmbyUserID)); err == nil {
|
||||||
|
var pool []recommend.MagicCandidate
|
||||||
|
if err := json.Unmarshal(raw, &pool); err == nil && len(pool) > 0 {
|
||||||
|
return pool, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.rebuildMagicPool(ctx, sess), false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) rebuildMagicPool(ctx context.Context, sess store.Session) []recommend.MagicCandidate {
|
||||||
|
pool := s.recommender.MagicPool(ctx, credentials(sess), time.Time{})
|
||||||
|
if len(pool) == 0 {
|
||||||
|
// Deliberately not cached: an empty pool is a household whose library or Emby was
|
||||||
|
// unavailable far more often than it is one with no films, and keeping that answer
|
||||||
|
// would withdraw the button for hours over a moment's trouble.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if raw, err := json.Marshal(pool); err == nil {
|
||||||
|
if err := s.cache.Set(ctx, cache.MagicPoolKey(sess.EmbyUserID), raw, s.cfg.MagicPoolTTL); err != nil {
|
||||||
|
s.loggerFor(ctx).Warn("magic pool cache write failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var releaseBuilderTagPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
|
|
||||||
|
|
||||||
type releaseBuilderRequest struct {
|
|
||||||
Tag string `json:"tag"`
|
|
||||||
Notes string `json:"notes"`
|
|
||||||
Mandatory bool `json:"mandatory"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAdminReleaseBuilderStatus(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.relayReleaseBuilder(w, r, http.MethodGet, "/v1/status", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) handleAdminReleaseBuilderStart(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if s.rejectWorkDuringQuietTime(w) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
|
||||||
var request releaseBuilderRequest
|
|
||||||
decoder := json.NewDecoder(r.Body)
|
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
if err := decoder.Decode(&request); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "invalid release request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request.Tag = strings.TrimSpace(request.Tag)
|
|
||||||
request.Notes = strings.TrimSpace(request.Notes)
|
|
||||||
if request.Tag != "" && !releaseBuilderTagPattern.MatchString(request.Tag) {
|
|
||||||
writeError(w, http.StatusBadRequest, "tag must be blank or look like v0.2.64")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(request.Notes) > 4000 {
|
|
||||||
writeError(w, http.StatusBadRequest, "release notes are too long")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
body, err := json.Marshal(request)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "could not prepare release request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.relayReleaseBuilder(w, r, http.MethodPost, "/v1/releases", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) relayReleaseBuilder(w http.ResponseWriter, incoming *http.Request, method, path string, body []byte) {
|
|
||||||
if s.cfg.ReleaseBuilderURL == "" || s.cfg.ReleasePublishToken == "" {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "the Docker release builder is not configured")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request, err := http.NewRequestWithContext(incoming.Context(), method,
|
|
||||||
s.cfg.ReleaseBuilderURL+path, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, "could not prepare builder request")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request.Header.Set("Authorization", "Bearer "+s.cfg.ReleasePublishToken)
|
|
||||||
if len(body) > 0 {
|
|
||||||
request.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
response, err := client.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "the Docker release builder is not available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer response.Body.Close()
|
|
||||||
payload, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadGateway, "could not read the Docker release builder response")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Header().Set("Cache-Control", "no-store")
|
|
||||||
w.WriteHeader(response.StatusCode)
|
|
||||||
_, _ = w.Write(payload)
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/ponzischeme89/memby/server/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAdminReleaseBuilderRelaysWithoutExposingToken(t *testing.T) {
|
|
||||||
var receivedAuth string
|
|
||||||
builder := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
receivedAuth = r.Header.Get("Authorization")
|
|
||||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/releases" {
|
|
||||||
t.Fatalf("builder request = %s %s", r.Method, r.URL.Path)
|
|
||||||
}
|
|
||||||
body, _ := io.ReadAll(r.Body)
|
|
||||||
if !strings.Contains(string(body), `"tag":"v0.2.64"`) || !strings.Contains(string(body), `"mandatory":true`) {
|
|
||||||
t.Fatalf("builder body = %s", body)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusAccepted)
|
|
||||||
_, _ = io.WriteString(w, `{"state":"running","logs":[]}`)
|
|
||||||
}))
|
|
||||||
defer builder.Close()
|
|
||||||
|
|
||||||
s := &Server{cfg: config.Config{ReleaseBuilderURL: builder.URL, ReleasePublishToken: "release-secret"}}
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
request := httptest.NewRequest(http.MethodPost, "/admin/api/release-builder",
|
|
||||||
strings.NewReader(`{"tag":"v0.2.64","notes":"Living room polish","mandatory":true}`))
|
|
||||||
s.handleAdminReleaseBuilderStart(recorder, request)
|
|
||||||
|
|
||||||
if recorder.Code != http.StatusAccepted || receivedAuth != "Bearer release-secret" {
|
|
||||||
t.Fatalf("response/auth = %d/%q", recorder.Code, receivedAuth)
|
|
||||||
}
|
|
||||||
if strings.Contains(recorder.Body.String(), "release-secret") {
|
|
||||||
t.Fatal("release token was exposed to the browser")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminReleaseBuilderValidatesTagBeforeRelay(t *testing.T) {
|
|
||||||
s := &Server{cfg: config.Config{ReleaseBuilderURL: "http://builder", ReleasePublishToken: "secret"}}
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
request := httptest.NewRequest(http.MethodPost, "/admin/api/release-builder",
|
|
||||||
strings.NewReader(`{"tag":"latest; rm -rf /"}`))
|
|
||||||
s.handleAdminReleaseBuilderStart(recorder, request)
|
|
||||||
if recorder.Code != http.StatusBadRequest {
|
|
||||||
t.Fatalf("invalid tag status = %d, want 400", recorder.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminReleaseBuilderIsUnavailableWhenUnconfigured(t *testing.T) {
|
|
||||||
s := &Server{}
|
|
||||||
recorder := httptest.NewRecorder()
|
|
||||||
s.handleAdminReleaseBuilderStatus(recorder, httptest.NewRequest(http.MethodGet, "/admin/api/release-builder", nil))
|
|
||||||
if recorder.Code != http.StatusServiceUnavailable {
|
|
||||||
t.Fatalf("unconfigured builder status = %d, want 503", recorder.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+6
@@ -88,5 +88,11 @@ func UserKey(userID, view string) string { return fmt.Sprintf("u:%s:%s", userID,
|
|||||||
// invalidation and expire on their own slow-moving daily cadence.
|
// invalidation and expire on their own slow-moving daily cadence.
|
||||||
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v3", userID) }
|
func RecommendationsKey(userID string) string { return fmt.Sprintf("r:%s:rows:v3", userID) }
|
||||||
|
|
||||||
|
// MagicPoolKey sits outside the `u:` namespace for the same reason RecommendationsKey
|
||||||
|
// does, and one more besides: a Magic press *is* a playback change, so a pool filed under
|
||||||
|
// the user's ordinary views would be invalidated by the very press that read it and every
|
||||||
|
// press would pay the full rebuild.
|
||||||
|
func MagicPoolKey(userID string) string { return fmt.Sprintf("m:%s:pool:v1", userID) }
|
||||||
|
|
||||||
// SessionKey caches a token→session lookup, keyed by token hash (never the token).
|
// SessionKey caches a token→session lookup, keyed by token hash (never the token).
|
||||||
func SessionKey(tokenHashHex string) string { return "sess:" + tokenHashHex }
|
func SessionKey(tokenHashHex string) string { return "sess:" + tokenHashHex }
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ type Config struct {
|
|||||||
// RecommendTimeout bounds a background rebuild, which fans out further than a
|
// RecommendTimeout bounds a background rebuild, which fans out further than a
|
||||||
// normal request and so needs more headroom than UpstreamTimeout.
|
// normal request and so needs more headroom than UpstreamTimeout.
|
||||||
RecommendTimeout time.Duration
|
RecommendTimeout time.Duration
|
||||||
|
// MagicPoolTTL is how long Magic's scored pool stays warm. Shorter than
|
||||||
|
// RecommendTTL, which is a daily rotation nobody is waiting on: this one is
|
||||||
|
// rebuilt in front of somebody standing at a player with the film paused
|
||||||
|
// behind a loading panel, and a household that has just acquired something
|
||||||
|
// should be able to be handed it the same evening.
|
||||||
|
MagicPoolTTL time.Duration
|
||||||
// RecommendationWeights is an optional JSON overlay on the weighted defaults.
|
// RecommendationWeights is an optional JSON overlay on the weighted defaults.
|
||||||
RecommendationWeights string
|
RecommendationWeights string
|
||||||
// RemoteConfig is the complete, validated presentation document served to TVs.
|
// RemoteConfig is the complete, validated presentation document served to TVs.
|
||||||
@@ -97,9 +103,6 @@ type Config struct {
|
|||||||
// ReleasePublishToken authorizes the CI-only release upload endpoint. It is separate
|
// ReleasePublishToken authorizes the CI-only release upload endpoint. It is separate
|
||||||
// from AdminToken so a compromised build runner cannot change maintenance settings.
|
// from AdminToken so a compromised build runner cannot change maintenance settings.
|
||||||
ReleasePublishToken string
|
ReleasePublishToken string
|
||||||
// ReleaseBuilderURL is the private Compose address of the Android release controller.
|
|
||||||
// It is never given to the browser; the authenticated admin API relays requests to it.
|
|
||||||
ReleaseBuilderURL string
|
|
||||||
|
|
||||||
// SyncInterval is how often the library import runs. Zero disables the schedule.
|
// SyncInterval is how often the library import runs. Zero disables the schedule.
|
||||||
SyncInterval time.Duration
|
SyncInterval time.Duration
|
||||||
@@ -221,6 +224,7 @@ func Load() (Config, error) {
|
|||||||
SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour),
|
SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour),
|
||||||
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 24*time.Hour),
|
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 24*time.Hour),
|
||||||
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
|
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
|
||||||
|
MagicPoolTTL: duration("MEMBY_MAGIC_POOL_TTL", 2*time.Hour),
|
||||||
RecommendationWeights: strings.TrimSpace(
|
RecommendationWeights: strings.TrimSpace(
|
||||||
os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"),
|
os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"),
|
||||||
),
|
),
|
||||||
@@ -231,7 +235,6 @@ func Load() (Config, error) {
|
|||||||
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
|
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
|
||||||
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
|
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
|
||||||
ReleasePublishToken: releasePublishToken,
|
ReleasePublishToken: releasePublishToken,
|
||||||
ReleaseBuilderURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RELEASE_BUILDER_URL")), "/"),
|
|
||||||
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
|
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
|
||||||
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
|
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
|
||||||
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
|
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ const (
|
|||||||
// nothing genuinely unsuitable can be drawn.
|
// nothing genuinely unsuitable can be drawn.
|
||||||
MagicPoolLimit = 40
|
MagicPoolLimit = 40
|
||||||
|
|
||||||
|
// MagicPoolReserve is how many scored titles [Engine.MagicPool] keeps, which is
|
||||||
|
// deliberately several times the hat. The pool is built once and drawn from many
|
||||||
|
// times, and every press narrows it further — the film playing now and the last few
|
||||||
|
// this button offered come out, and a viewer who said how long they had re-ranks
|
||||||
|
// what is left. Reserving only the hat's own size would leave a household with
|
||||||
|
// nothing to draw after a handful of presses.
|
||||||
|
MagicPoolReserve = MagicPoolLimit * 3
|
||||||
|
|
||||||
// magicUnwatchedBonus is the largest single term, because "something I have not seen"
|
// magicUnwatchedBonus is the largest single term, because "something I have not seen"
|
||||||
// is most of what somebody means by the button.
|
// is most of what somebody means by the button.
|
||||||
magicUnwatchedBonus = 1.4
|
magicUnwatchedBonus = 1.4
|
||||||
@@ -63,13 +71,47 @@ type MagicOptions struct {
|
|||||||
// draw is deterministic under test — and so that the *only* non-deterministic thing
|
// draw is deterministic under test — and so that the *only* non-deterministic thing
|
||||||
// about this feature sits in one named parameter.
|
// about this feature sits in one named parameter.
|
||||||
Roll float64
|
Roll float64
|
||||||
// Now is injectable for the same reason.
|
// Now is injectable for the same reason. It reaches the pool rather than the draw —
|
||||||
|
// the only thing it decides is what counts as recently added.
|
||||||
Now time.Time
|
Now time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MagicCandidate is one title already weighed, reduced to what a draw needs and nothing
|
||||||
|
// more.
|
||||||
|
//
|
||||||
|
// It exists because the two halves of this feature have completely different costs. Working
|
||||||
|
// out what the viewer likes is two full reads of their Emby history plus a catalogue query;
|
||||||
|
// drawing from the result is arithmetic over a few dozen numbers. Separating them is what
|
||||||
|
// lets the expensive half be done once and kept, while every press still gets its own
|
||||||
|
// genuinely unpredictable answer — the property the button cannot lose. It is JSON-tagged
|
||||||
|
// because being cached is the whole point of the separation.
|
||||||
|
type MagicCandidate struct {
|
||||||
|
ItemID string `json:"itemId"`
|
||||||
|
// Title is carried so a draw can be logged by name without re-reading the item.
|
||||||
|
Title string `json:"title"`
|
||||||
|
// Score is everything the profile had to say, which is fixed for as long as the pool
|
||||||
|
// is. The request-scoped terms are applied at the draw.
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
// RuntimeMinutes is kept rather than folded into the score because "there is an hour
|
||||||
|
// before bed" is a property of the press, not of the title.
|
||||||
|
RuntimeMinutes int `json:"runtimeMinutes,omitempty"`
|
||||||
|
// Signals is why this title was eligible, in machine-readable slugs. Not shown.
|
||||||
|
Signals []string `json:"signals,omitempty"`
|
||||||
|
// Reasons is viewer-facing wording from the same explanation layer a detail page
|
||||||
|
// uses. It is computed here rather than at the draw because it needs the profile,
|
||||||
|
// which is exactly what the pool exists to avoid rebuilding.
|
||||||
|
Reasons []string `json:"reasons,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// MagicSelection is one drawn title with its evidence.
|
// MagicSelection is one drawn title with its evidence.
|
||||||
|
//
|
||||||
|
// It carries the item's id and name rather than the item itself: a draw may be made from a
|
||||||
|
// pool built hours ago, and the caller re-reads the record it is about to hand a television
|
||||||
|
// regardless — which is one lookup, against a title somebody is about to watch for two
|
||||||
|
// hours.
|
||||||
type MagicSelection struct {
|
type MagicSelection struct {
|
||||||
Item Item
|
ItemID string
|
||||||
|
Title string
|
||||||
// Reasons is viewer-facing wording from the same explanation layer a detail page uses.
|
// Reasons is viewer-facing wording from the same explanation layer a detail page uses.
|
||||||
Reasons []string
|
Reasons []string
|
||||||
// Signals is why this title was *eligible*, in machine-readable slugs, so the choice
|
// Signals is why this title was *eligible*, in machine-readable slugs, so the choice
|
||||||
@@ -82,38 +124,70 @@ type MagicSelection struct {
|
|||||||
PoolSize int
|
PoolSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
// MagicPick gathers the signals and draws. Errors only when the profile cannot be built at
|
// MagicPool does the expensive half: the taste profile, the candidate query and the
|
||||||
// all and the catalogue is empty with it — every lesser failure degrades, on the principle
|
// weighing. Nothing about it is request-scoped, which is what makes it safe to keep.
|
||||||
// [Engine.RelatedTo] already applies: a button that sometimes does nothing is worse than one
|
//
|
||||||
// that occasionally picks less well.
|
// It never errors. A profile that cannot be built costs the weighting and not the button,
|
||||||
func (e *Engine) MagicPick(
|
// on the principle [Engine.RelatedTo] already applies — an empty pool is the one failure,
|
||||||
|
// and it means a household with no films rather than a server having trouble.
|
||||||
|
func (e *Engine) MagicPool(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
cred emby.Credentials,
|
cred emby.Credentials,
|
||||||
opts MagicOptions,
|
now time.Time,
|
||||||
) (MagicSelection, bool) {
|
) []MagicCandidate {
|
||||||
if opts.Now.IsZero() {
|
if now.IsZero() {
|
||||||
opts.Now = e.now()
|
now = e.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
history, favorites, err := e.gatherSignals(ctx, cred)
|
history, favorites, err := e.gatherSignals(ctx, cred)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// A profile that cannot be built costs the weighting, not the button. What is left
|
// What is left is an unweighted draw over the catalogue, which is still "put
|
||||||
// is an unweighted draw over the catalogue, which is still "put something on".
|
// something on".
|
||||||
e.log.Warn("magic signals unavailable; drawing without taste", "error", err)
|
e.log.Warn("magic signals unavailable; drawing without taste", "error", err)
|
||||||
}
|
}
|
||||||
profile := BuildProfile(history, favorites)
|
profile := BuildProfile(history, favorites)
|
||||||
|
|
||||||
candidates := e.magicCandidates(ctx, cred, profile)
|
candidates := e.magicCandidates(ctx, cred, profile)
|
||||||
if len(candidates) == 0 {
|
if len(candidates) == 0 {
|
||||||
return MagicSelection{}, false
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
selection, ok := ChooseMagic(profile, candidates, opts)
|
pool := make([]MagicCandidate, 0, len(candidates))
|
||||||
if !ok {
|
byID := make(map[string]Item, len(candidates))
|
||||||
return MagicSelection{}, false
|
for _, candidate := range candidates {
|
||||||
|
if candidate.ID == "" || byID[candidate.ID].ID != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
byID[candidate.ID] = candidate
|
||||||
|
score, signals := magicScore(profile, candidate, now)
|
||||||
|
pool = append(pool, MagicCandidate{
|
||||||
|
ItemID: candidate.ID,
|
||||||
|
Title: candidate.Name,
|
||||||
|
Score: score,
|
||||||
|
RuntimeMinutes: candidate.RuntimeMinutes(),
|
||||||
|
Signals: signals,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
selection.Reasons = Why(profile, selection.Item, 2)
|
sortMagicPool(pool)
|
||||||
return selection, true
|
if len(pool) > MagicPoolReserve {
|
||||||
|
pool = pool[:MagicPoolReserve]
|
||||||
|
}
|
||||||
|
// Worded only for what survived the reserve: the explanation layer runs per title, and
|
||||||
|
// wording several hundred nobody will ever be offered is work thrown away.
|
||||||
|
for i := range pool {
|
||||||
|
pool[i].Reasons = Why(profile, byID[pool[i].ItemID], 2)
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MagicPick builds a pool and draws from it in one go — the whole feature for a caller with
|
||||||
|
// nowhere to keep the pool, and what the tests exercise.
|
||||||
|
func (e *Engine) MagicPick(
|
||||||
|
ctx context.Context,
|
||||||
|
cred emby.Credentials,
|
||||||
|
opts MagicOptions,
|
||||||
|
) (MagicSelection, bool) {
|
||||||
|
return ChooseMagic(e.MagicPool(ctx, cred, opts.Now), opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// magicCandidates prefers the imported catalogue, which costs Postgres one read rather than
|
// magicCandidates prefers the imported catalogue, which costs Postgres one read rather than
|
||||||
@@ -175,7 +249,7 @@ func onlyMovies(items []Item) []Item {
|
|||||||
// household would get the same film every night, which is the one outcome the button cannot
|
// household would get the same film every night, which is the one outcome the button cannot
|
||||||
// have. Ranking then *drawing from the ranking* keeps merit deciding which titles are in the
|
// have. Ranking then *drawing from the ranking* keeps merit deciding which titles are in the
|
||||||
// hat and how many tickets each holds, while leaving the answer genuinely unpredictable.
|
// hat and how many tickets each holds, while leaving the answer genuinely unpredictable.
|
||||||
func ChooseMagic(profile Profile, candidates []Item, opts MagicOptions) (MagicSelection, bool) {
|
func ChooseMagic(candidates []MagicCandidate, opts MagicOptions) (MagicSelection, bool) {
|
||||||
excluded := map[string]bool{}
|
excluded := map[string]bool{}
|
||||||
for _, id := range opts.ExcludeIDs {
|
for _, id := range opts.ExcludeIDs {
|
||||||
if id = strings.TrimSpace(id); id != "" {
|
if id = strings.TrimSpace(id); id != "" {
|
||||||
@@ -183,32 +257,28 @@ func ChooseMagic(profile Profile, candidates []Item, opts MagicOptions) (MagicSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type scored struct {
|
pool := make([]MagicCandidate, 0, len(candidates))
|
||||||
item Item
|
|
||||||
score float64
|
|
||||||
signals []string
|
|
||||||
}
|
|
||||||
pool := make([]scored, 0, len(candidates))
|
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if candidate.ID == "" || excluded[candidate.ID] || seen[candidate.ID] {
|
if candidate.ItemID == "" || excluded[candidate.ItemID] || seen[candidate.ItemID] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[candidate.ID] = true
|
seen[candidate.ItemID] = true
|
||||||
score, signals := magicScore(profile, candidate, opts)
|
// Only the terms that belong to this press: everything the profile had to say is
|
||||||
pool = append(pool, scored{item: candidate, score: score, signals: signals})
|
// already in the score the pool was built with.
|
||||||
|
if adjustment, signal := magicRuntimeAdjustment(
|
||||||
|
candidate.RuntimeMinutes, opts.AvailableMinutes,
|
||||||
|
); signal != "" {
|
||||||
|
candidate.Score += adjustment
|
||||||
|
candidate.Signals = append(append([]string(nil), candidate.Signals...), signal)
|
||||||
|
}
|
||||||
|
pool = append(pool, candidate)
|
||||||
}
|
}
|
||||||
if len(pool) == 0 {
|
if len(pool) == 0 {
|
||||||
return MagicSelection{}, false
|
return MagicSelection{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.SliceStable(pool, func(i, j int) bool {
|
sortMagicPool(pool)
|
||||||
if pool[i].score != pool[j].score {
|
|
||||||
return pool[i].score > pool[j].score
|
|
||||||
}
|
|
||||||
// Ties break by id so the *pool* is reproducible even though the draw is not.
|
|
||||||
return pool[i].item.ID < pool[j].item.ID
|
|
||||||
})
|
|
||||||
if len(pool) > MagicPoolLimit {
|
if len(pool) > MagicPoolLimit {
|
||||||
pool = pool[:MagicPoolLimit]
|
pool = pool[:MagicPoolLimit]
|
||||||
}
|
}
|
||||||
@@ -226,27 +296,42 @@ func ChooseMagic(profile Profile, candidates []Item, opts MagicOptions) (MagicSe
|
|||||||
for index, entry := range pool {
|
for index, entry := range pool {
|
||||||
cumulative += float64(len(pool) - index)
|
cumulative += float64(len(pool) - index)
|
||||||
if target < cumulative {
|
if target < cumulative {
|
||||||
return MagicSelection{
|
return magicSelection(entry, len(pool)), true
|
||||||
Item: entry.item,
|
|
||||||
Signals: entry.signals,
|
|
||||||
Score: entry.score,
|
|
||||||
PoolSize: len(pool),
|
|
||||||
}, true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
last := pool[len(pool)-1]
|
return magicSelection(pool[len(pool)-1], len(pool)), true
|
||||||
return MagicSelection{
|
|
||||||
Item: last.item,
|
|
||||||
Signals: last.signals,
|
|
||||||
Score: last.score,
|
|
||||||
PoolSize: len(pool),
|
|
||||||
}, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// magicScore sums the stated terms and reports which of them fired. The signals are the
|
func magicSelection(entry MagicCandidate, poolSize int) MagicSelection {
|
||||||
// point of returning two values: a weighting nobody can see the workings of is a weighting
|
return MagicSelection{
|
||||||
// nobody can improve.
|
ItemID: entry.ItemID,
|
||||||
func magicScore(profile Profile, item Item, opts MagicOptions) (float64, []string) {
|
Title: entry.Title,
|
||||||
|
Reasons: entry.Reasons,
|
||||||
|
Signals: entry.Signals,
|
||||||
|
Score: entry.Score,
|
||||||
|
PoolSize: poolSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortMagicPool orders by merit, with ties broken by id so the *pool* is reproducible even
|
||||||
|
// though the draw from it is not. It is one function because the pool is ordered twice — as
|
||||||
|
// it is built and again after a press has adjusted it — and two copies of a comparison is
|
||||||
|
// how the two orders come to disagree.
|
||||||
|
func sortMagicPool(pool []MagicCandidate) {
|
||||||
|
sort.SliceStable(pool, func(i, j int) bool {
|
||||||
|
if pool[i].Score != pool[j].Score {
|
||||||
|
return pool[i].Score > pool[j].Score
|
||||||
|
}
|
||||||
|
return pool[i].ItemID < pool[j].ItemID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// magicScore sums the terms that belong to the *title*, and reports which of them fired.
|
||||||
|
// The signals are the point of returning two values: a weighting nobody can see the
|
||||||
|
// workings of is a weighting nobody can improve.
|
||||||
|
//
|
||||||
|
// The runtime fit is deliberately not here — see [magicRuntimeAdjustment].
|
||||||
|
func magicScore(profile Profile, item Item, now time.Time) (float64, []string) {
|
||||||
signals := make([]string, 0, 6)
|
signals := make([]string, 0, 6)
|
||||||
score := profile.Affinity(item)
|
score := profile.Affinity(item)
|
||||||
if score > 0 {
|
if score > 0 {
|
||||||
@@ -266,28 +351,33 @@ func magicScore(profile Profile, item Item, opts MagicOptions) (float64, []strin
|
|||||||
signals = append(signals, "favourite")
|
signals = append(signals, "favourite")
|
||||||
}
|
}
|
||||||
|
|
||||||
if addedDays, ok := daysSince(item.DateCreated, opts.Now); ok && addedDays <= magicRecentlyAddedDays {
|
if addedDays, ok := daysSince(item.DateCreated, now); ok && addedDays <= magicRecentlyAddedDays {
|
||||||
score += magicRecentlyAddedBonus
|
score += magicRecentlyAddedBonus
|
||||||
signals = append(signals, "recently_added")
|
signals = append(signals, "recently_added")
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.AvailableMinutes > 0 {
|
|
||||||
switch runtime := item.RuntimeMinutes(); {
|
|
||||||
case runtime <= 0:
|
|
||||||
// Nothing recorded is not evidence either way, and refusing to draw it would
|
|
||||||
// quietly delete a slice of the library from the feature.
|
|
||||||
case runtime > opts.AvailableMinutes+magicRuntimeSlackMinutes:
|
|
||||||
score -= magicRuntimeOverPenalty
|
|
||||||
signals = append(signals, "too_long")
|
|
||||||
default:
|
|
||||||
score += magicRuntimeFitBonus
|
|
||||||
signals = append(signals, "fits_time")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return score, signals
|
return score, signals
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// magicRuntimeAdjustment is how "there is an hour before bed" gets a different answer from
|
||||||
|
// "it is Saturday afternoon". It is applied at the draw rather than folded into the pool
|
||||||
|
// because it belongs to the press: the same pool has to be able to answer both questions.
|
||||||
|
//
|
||||||
|
// An empty signal means the term did not apply at all, which covers both "no limit was
|
||||||
|
// given" and "this title has no runtime recorded" — nothing recorded is not evidence
|
||||||
|
// either way, and refusing to draw it would quietly delete a slice of the library from the
|
||||||
|
// feature.
|
||||||
|
func magicRuntimeAdjustment(runtimeMinutes, availableMinutes int) (float64, string) {
|
||||||
|
switch {
|
||||||
|
case availableMinutes <= 0, runtimeMinutes <= 0:
|
||||||
|
return 0, ""
|
||||||
|
case runtimeMinutes > availableMinutes+magicRuntimeSlackMinutes:
|
||||||
|
return -magicRuntimeOverPenalty, "too_long"
|
||||||
|
default:
|
||||||
|
return magicRuntimeFitBonus, "fits_time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// daysSince reads Emby's ISO-8601 DateCreated. A field that is absent or unreadable is not
|
// daysSince reads Emby's ISO-8601 DateCreated. A field that is absent or unreadable is not
|
||||||
// an error: it simply cannot earn the recently-added bonus.
|
// an error: it simply cannot earn the recently-added bonus.
|
||||||
func daysSince(value string, now time.Time) (int, bool) {
|
func daysSince(value string, now time.Time) (int, bool) {
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package recommend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func magicCandidate(id string, score float64, runtimeMinutes int) MagicCandidate {
|
||||||
|
return MagicCandidate{ItemID: id, Title: id, Score: score, RuntimeMinutes: runtimeMinutes}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool is kept between presses, so it has to survive the round trip that keeping it
|
||||||
|
// means. A field that lost its tag would show up as a button that quietly stopped weighing
|
||||||
|
// anything rather than as an error.
|
||||||
|
func TestMagicCandidateSurvivesBeingKept(t *testing.T) {
|
||||||
|
pool := []MagicCandidate{{
|
||||||
|
ItemID: "1", Title: "A Film", Score: 2.5, RuntimeMinutes: 104,
|
||||||
|
Signals: []string{"unwatched"}, Reasons: []string{"Because you like Drama"},
|
||||||
|
}}
|
||||||
|
raw, err := json.Marshal(pool)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
var restored []MagicCandidate
|
||||||
|
if err := json.Unmarshal(raw, &restored); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(restored) != 1 {
|
||||||
|
t.Fatalf("pool length = %d, want 1", len(restored))
|
||||||
|
}
|
||||||
|
got, want := restored[0], pool[0]
|
||||||
|
if got.ItemID != want.ItemID || got.Title != want.Title || got.Score != want.Score ||
|
||||||
|
got.RuntimeMinutes != want.RuntimeMinutes ||
|
||||||
|
len(got.Signals) != len(want.Signals) || len(got.Reasons) != len(want.Reasons) {
|
||||||
|
t.Fatalf("pool did not survive being kept: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChooseMagicNeverReturnsAnExcludedTitle(t *testing.T) {
|
||||||
|
pool := []MagicCandidate{
|
||||||
|
magicCandidate("playing-now", 9, 0),
|
||||||
|
magicCandidate("offered-before", 8, 0),
|
||||||
|
magicCandidate("fresh", 1, 0),
|
||||||
|
}
|
||||||
|
for roll := 0.0; roll < 1; roll += 0.01 {
|
||||||
|
selection, ok := ChooseMagic(pool, MagicOptions{
|
||||||
|
ExcludeIDs: []string{"playing-now", " offered-before "},
|
||||||
|
Roll: roll,
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("roll %.2f: expected a pick", roll)
|
||||||
|
}
|
||||||
|
if selection.ItemID != "fresh" {
|
||||||
|
t.Fatalf("roll %.2f: drew an excluded title %q", roll, selection.ItemID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A household that has been offered everything the pool holds is the one case the button
|
||||||
|
// has no answer for, and it must say so rather than repeat itself.
|
||||||
|
func TestChooseMagicRefusesWhenEverythingIsExcluded(t *testing.T) {
|
||||||
|
pool := []MagicCandidate{magicCandidate("only", 3, 0)}
|
||||||
|
if _, ok := ChooseMagic(pool, MagicOptions{ExcludeIDs: []string{"only"}, Roll: 0.5}); ok {
|
||||||
|
t.Fatal("expected no pick when the whole pool is excluded")
|
||||||
|
}
|
||||||
|
if _, ok := ChooseMagic(nil, MagicOptions{Roll: 0.5}); ok {
|
||||||
|
t.Fatal("expected no pick from an empty pool")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole reason for drawing rather than sorting: pressing it twice must be able to give
|
||||||
|
// two answers, while merit still decides how many tickets each title holds.
|
||||||
|
func TestChooseMagicFavoursMeritWithoutBeingAForegoneConclusion(t *testing.T) {
|
||||||
|
pool := make([]MagicCandidate, 0, 10)
|
||||||
|
for index := 0; index < 10; index++ {
|
||||||
|
pool = append(pool, magicCandidate(string(rune('a'+index)), float64(10-index), 0))
|
||||||
|
}
|
||||||
|
counts := map[string]int{}
|
||||||
|
for roll := 0.0; roll < 1; roll += 0.001 {
|
||||||
|
selection, ok := ChooseMagic(pool, MagicOptions{Roll: roll})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("roll %.3f: expected a pick", roll)
|
||||||
|
}
|
||||||
|
counts[selection.ItemID]++
|
||||||
|
}
|
||||||
|
if len(counts) != len(pool) {
|
||||||
|
t.Fatalf("every title should be reachable, got %d of %d", len(counts), len(pool))
|
||||||
|
}
|
||||||
|
if counts["a"] <= counts["j"] {
|
||||||
|
t.Fatalf("the best title should hold the most tickets: %v", counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool is built once and asked more than one question, so the time budget cannot have
|
||||||
|
// been folded into it. The same pool has to answer "there is an hour" differently from
|
||||||
|
// "it is Saturday afternoon".
|
||||||
|
func TestChooseMagicAppliesTheTimeBudgetAtTheDraw(t *testing.T) {
|
||||||
|
pool := []MagicCandidate{
|
||||||
|
magicCandidate("epic", 1.0, 180),
|
||||||
|
magicCandidate("short", 0.6, 85),
|
||||||
|
}
|
||||||
|
unhurried, ok := ChooseMagic(pool, MagicOptions{Roll: 0})
|
||||||
|
if !ok || unhurried.ItemID != "epic" {
|
||||||
|
t.Fatalf("with no limit the better title should lead, got %+v", unhurried)
|
||||||
|
}
|
||||||
|
rushed, ok := ChooseMagic(pool, MagicOptions{AvailableMinutes: 90, Roll: 0})
|
||||||
|
if !ok || rushed.ItemID != "short" {
|
||||||
|
t.Fatalf("with 90 minutes the one that fits should lead, got %+v", rushed)
|
||||||
|
}
|
||||||
|
if !hasSignal(rushed.Signals, "fits_time") {
|
||||||
|
t.Fatalf("the fit should be reported as a signal: %v", rushed.Signals)
|
||||||
|
}
|
||||||
|
// And the pool itself must be unchanged by having been asked, or the second press
|
||||||
|
// would inherit the first press's constraints.
|
||||||
|
if pool[0].Score != 1.0 || len(pool[0].Signals) != 0 {
|
||||||
|
t.Fatalf("the draw mutated the kept pool: %+v", pool[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing recorded is not evidence either way: refusing those titles would quietly delete
|
||||||
|
// a slice of the library from the feature.
|
||||||
|
func TestMagicRuntimeAdjustmentStaysSilentWithoutEvidence(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
runtimeMinutes, availableMinutes int
|
||||||
|
wantSignal string
|
||||||
|
}{
|
||||||
|
{"no limit given", 200, 0, ""},
|
||||||
|
{"no runtime recorded", 0, 60, ""},
|
||||||
|
{"comfortably inside", 85, 90, "fits_time"},
|
||||||
|
{"inside the slack", 95, 90, "fits_time"},
|
||||||
|
{"past the slack", 101, 90, "too_long"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, signal := magicRuntimeAdjustment(tc.runtimeMinutes, tc.availableMinutes)
|
||||||
|
if signal != tc.wantSignal {
|
||||||
|
t.Fatalf("signal = %q, want %q", signal, tc.wantSignal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ties break by id so that the pool is reproducible even though the draw from it is not.
|
||||||
|
func TestSortMagicPoolIsReproducible(t *testing.T) {
|
||||||
|
pool := []MagicCandidate{
|
||||||
|
magicCandidate("z", 2, 0),
|
||||||
|
magicCandidate("a", 2, 0),
|
||||||
|
magicCandidate("m", 5, 0),
|
||||||
|
}
|
||||||
|
sortMagicPool(pool)
|
||||||
|
got := []string{pool[0].ItemID, pool[1].ItemID, pool[2].ItemID}
|
||||||
|
want := []string{"m", "a", "z"}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("order = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasSignal(signals []string, want string) bool {
|
||||||
|
for _, signal := range signals {
|
||||||
|
if signal == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user