0.2.73
This commit is contained in:
+2
-3
@@ -32,11 +32,10 @@ local.properties
|
||||
*.secret
|
||||
/secrets/
|
||||
|
||||
# Local environment variants may contain host paths or release settings. The two
|
||||
# checked-in examples contain names/placeholders only.
|
||||
# Local environment variants may contain host paths or release settings. The
|
||||
# checked-in example contains names/placeholders only.
|
||||
.env.*
|
||||
!.env.example
|
||||
!builder.env.example
|
||||
|
||||
# The installed APK a deploy script pulls off a television to compare against.
|
||||
/.tmp-memby-installed-base.apk
|
||||
|
||||
@@ -80,7 +80,7 @@ docker compose up -d --build # from the repo root; needs .env (see .env.e
|
||||
```powershell
|
||||
.\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 -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
|
||||
@@ -88,9 +88,9 @@ one SSH connection (interactive password; stdin carries the
|
||||
archive, so OpenSSH prompts on the tty). The remote half stages into
|
||||
`<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
|
||||
preserved — it never runs `compose down -v`. For a server/admin-only change, combine
|
||||
`-SkipAppRelease -SkipBuilder`: this skips the local APK build and preserves the running
|
||||
Android builder container and image while only the gateway and admin console are replaced.
|
||||
preserved — it never runs `compose down -v`. For a server/admin-only change, pass
|
||||
`-SkipAppRelease`: this skips the local APK build so only the gateway and admin console are
|
||||
replaced.
|
||||
|
||||
**`.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
|
||||
@@ -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`
|
||||
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
|
||||
The normal NAS path is Admin Console → App updates → **Build latest release**. The isolated
|
||||
service under `builder/` fetches the highest semantic GitHub tag, builds that exact source,
|
||||
verifies the APK and publishes it through the gateway into the shared `memby-releases`
|
||||
volume. An exact tag may be selected as a deliberate fallback, and
|
||||
`docker compose run --rm --build memby-builder release` remains the controller-independent
|
||||
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`.
|
||||
**Releases.** APKs are self-hosted (NAS or any web server), not on a store. An APK is built
|
||||
on a workstation or by CI and published to the gateway through `POST /admin/api/release`,
|
||||
which stores it in the shared `memby-releases` volume — either by `deploy-server.ps1` (which
|
||||
builds, signs, verifies and publishes in the same operation) or by
|
||||
`.gitea/workflows/release.yml` on a pushed semantic tag. The gateway itself builds nothing;
|
||||
its release-publish token is a read-only Compose secret under `/run/secrets`.
|
||||
|
||||
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
|
||||
|
||||
@@ -1,11 +1,68 @@
|
||||
# Memby (Android TV)
|
||||
<div align="center">
|
||||
|
||||
An independent Android TV client for Emby, by **ponzischeme89**. Memby combines a
|
||||
personalised television launcher, full media player, system screensaver and an optional
|
||||
self-hosted gateway that shapes the experience for every viewer using your library.
|
||||
<img src="memby-icon.png" alt="Memby" width="128" height="128">
|
||||
|
||||
# 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)
|
||||
|
||||
</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
|
||||
|
||||
### Browse and discover
|
||||
@@ -59,12 +116,79 @@ Source: [g.sublogue.com/admin/memby](https://g.sublogue.com/admin/memby)
|
||||
|
||||
## Tech stack
|
||||
|
||||
- Kotlin + Jetpack **Compose for TV** (`androidx.tv:tv-material3`)
|
||||
- **Media3 / ExoPlayer** for playback
|
||||
- Retrofit + OkHttp + kotlinx.serialization for the Emby REST API
|
||||
- DataStore for persisted profiles, settings and cached launcher state
|
||||
- Coil for backdrop image loading
|
||||
- Go gateway with Postgres and Redis
|
||||
| Layer | Built with |
|
||||
| --- | --- |
|
||||
| UI | Kotlin + Jetpack **Compose for TV** (`androidx.tv:tv-material3`) |
|
||||
| Playback | **Media3 / ExoPlayer** |
|
||||
| Networking | Retrofit + OkHttp + kotlinx.serialization, over one shared HTTP stack |
|
||||
| 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
|
||||
|
||||
@@ -90,98 +214,21 @@ app/src/main/java/com/ponzischeme89/memby/
|
||||
player/PlayerActivity.kt Media3 playback
|
||||
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
|
||||
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
|
||||
|
||||
Memby is handed out as an APK from your own web server or NAS, and updates itself from
|
||||
the same folder.
|
||||
Memby is handed out as an APK from your own web server or NAS, and updates itself from the
|
||||
same folder.
|
||||
|
||||
### One-time: a signing key
|
||||
|
||||
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
|
||||
every user has to uninstall and reinstall.
|
||||
signed with the *same* key, or the TV rejects it as a different app. Lose the key and every
|
||||
user has to uninstall and reinstall.
|
||||
|
||||
```powershell
|
||||
keytool -genkeypair -v -keystore memby-release.jks -alias memby `
|
||||
@@ -198,30 +245,23 @@ memby.keyAlias=memby
|
||||
memby.keyPassword=…
|
||||
```
|
||||
|
||||
Without these, `assembleRelease` still builds but the APK is unsigned and will not
|
||||
install. The build prints a warning saying so.
|
||||
Without these, `assembleRelease` still builds but the APK is unsigned and will not install.
|
||||
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
|
||||
Android SDK out of the gateway image, fetches the newest semantic GitHub tag, builds with
|
||||
the tagged source's Gradle wrapper, signs with the existing externally mounted keystore,
|
||||
verifies the APK and publishes it into the gateway's existing release volume and update
|
||||
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
|
||||
```
|
||||
`deploy-server.ps1` builds and publishes the TV app in the same operation that replaces the
|
||||
gateway: it signs with the existing keystore, verifies the APK and publishes it through
|
||||
`POST /admin/api/release` once the new gateway is healthy. A pushed semantic tag does the
|
||||
same thing through `.gitea/workflows/release.yml`. The gateway builds nothing itself.
|
||||
|
||||
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.sha256`. Gradle downloads are retained in the
|
||||
`memby-gradle-cache` volume. The keystore and credentials remain read-only Compose secrets
|
||||
under `/run/secrets` and are never copied into the image or repository.
|
||||
`/data/releases/memby-<version>.apk` and `/data/releases/memby-<version>.apk.sha256`. The
|
||||
release-publish credential is a read-only Compose secret under `/run/secrets` and is 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)
|
||||
|
||||
@@ -231,9 +271,8 @@ under `/run/secrets` and are never copied into the image or repository.
|
||||
-SourceUrl https://g.sublogue.com/admin/memby
|
||||
```
|
||||
|
||||
That existing workflow still bumps `versionName`/`versionCode`, runs the tests, builds a
|
||||
signed APK, and fills
|
||||
`dist/out/` with:
|
||||
That workflow bumps `versionName`/`versionCode`, runs the tests, builds a signed APK, and
|
||||
fills `dist/out/` with:
|
||||
|
||||
```
|
||||
index.html the page people are sent to
|
||||
@@ -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
|
||||
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 the download address are managed in the gateway's admin console; viewers do not need
|
||||
to configure an update source on the television.
|
||||
and the download address are managed in the gateway's admin console; viewers do not need to
|
||||
configure an update source on the television.
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
2. Sign in again; the previous session does not carry over.
|
||||
3. Uninstall the old app: `adb uninstall com.mattcohen.embyclientsname`.
|
||||
4. Re-select Memby in the TV's Screensaver settings — the Dream's component name changed
|
||||
as well, so the old selection no longer resolves.
|
||||
4. Re-select Memby in the TV's Screensaver settings — the Dream's component name changed as
|
||||
well, so the old selection no longer resolves.
|
||||
|
||||
## Notes & limitations
|
||||
|
||||
- Playback negotiates through Emby's `PlaybackInfo` endpoint, then streams directly from
|
||||
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
|
||||
file. The Moonfin-derived audio path reports formats the TV can decode or bitstream, offers
|
||||
automatic receiver detection and manual per-codec passthrough overrides, and decodes the
|
||||
rest to PCM through Media3's FFmpeg renderer. Emby can preserve a supported video stream
|
||||
while converting only incompatible audio or subtitles. If a vendor decoder still fails,
|
||||
Media3 tries another decoder and Memby ultimately requests an H.264 HLS transcode instead
|
||||
of abandoning playback.
|
||||
- Cleartext HTTP is enabled so local `http://` servers work out of the box. For an HTTPS-only
|
||||
server this is unnecessary but harmless.
|
||||
file. The Moonfin-derived audio path reports formats the TV can decode or bitstream,
|
||||
offers automatic receiver detection and manual per-codec passthrough overrides, and
|
||||
decodes the rest to PCM through Media3's FFmpeg renderer. Emby can preserve a supported
|
||||
video stream while converting only incompatible audio or subtitles. If a vendor decoder
|
||||
still fails, Media3 tries another decoder and Memby ultimately requests an H.264 HLS
|
||||
transcode instead of abandoning playback.
|
||||
- Cleartext HTTP is enabled so local `http://` servers work out of the box. For an
|
||||
HTTPS-only server this is unnecessary but harmless.
|
||||
- The device is remembered across sign-outs (stable `DeviceId`); credentials are cleared.
|
||||
|
||||
## Licence
|
||||
|
||||
Memby is free software licensed under the [GNU General Public License v2](LICENSE).
|
||||
Copyright and third-party acknowledgements are recorded in [NOTICE](NOTICE). Distributed
|
||||
APKs and server binaries must be accompanied by the corresponding source in accordance
|
||||
with GPLv2. The Android TV app also exposes the source link, notices, and complete licence
|
||||
under **Settings → About / Licences**.
|
||||
APKs and server binaries must be accompanied by the corresponding source in accordance with
|
||||
GPLv2. The Android TV app also exposes the source link, notices, and complete licence under
|
||||
**Settings → About / Licences**.
|
||||
|
||||
Playback capability probing and device-profile generation contain GPLv2 adaptations from
|
||||
[Wholphin](https://github.com/damontecres/Wholphin), itself derived in part from
|
||||
|
||||
@@ -114,17 +114,6 @@ export interface HeroPolicy {
|
||||
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 interface HeroPlacementPolicy {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { ReleaseBuilderStatus } from '../api/types';
|
||||
import { useAction } from '../lib/hooks';
|
||||
import { useGateway } from '../lib/gateway';
|
||||
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 {
|
||||
version: string;
|
||||
@@ -21,13 +20,6 @@ export function UpdatesPage() {
|
||||
const { busy, run } = useAction();
|
||||
const [draft, setDraft] = useState<Draft | null>(null);
|
||||
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;
|
||||
|
||||
@@ -47,32 +39,6 @@ export function UpdatesPage() {
|
||||
});
|
||||
}, [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) =>
|
||||
run(enabled ? 'save' : 'off', async () => {
|
||||
if (!draft) return;
|
||||
@@ -100,92 +66,10 @@ export function UpdatesPage() {
|
||||
|
||||
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 (
|
||||
<>
|
||||
<PageHead title="App updates" intro="Publish an optional or a required client update." />
|
||||
<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 rows={1} />
|
||||
@@ -303,17 +187,6 @@ export function UpdatesPage() {
|
||||
onCancel={() => setConfirming(false)}
|
||||
/>
|
||||
) : 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
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.2.72"
|
||||
val defaultVersionName = "0.2.73"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.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
|
||||
* 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(
|
||||
offset: Int = 0,
|
||||
limit: Int = GENRE_PAGE_SIZE,
|
||||
itemType: String,
|
||||
itemType: String?,
|
||||
): 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) {
|
||||
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) }
|
||||
.onFailure { error ->
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
if (offset > 0) throw error
|
||||
// An older gateway has no whole-library route. Its home response is a
|
||||
// useful first shelf and, crucially, is not claimed to be pageable.
|
||||
// An older gateway has no whole-library route, and one older still
|
||||
// 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 types = embyItemType.split(',')
|
||||
val items = (home.rows.asSequence().flatMap { it.items.asSequence() } +
|
||||
home.continueWatching.asSequence() + home.nextUp.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)
|
||||
.take(limit)
|
||||
.toList()
|
||||
|
||||
@@ -63,6 +63,10 @@ data class NavigationLabels(
|
||||
val search: String,
|
||||
val movies: 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 favourites: String,
|
||||
val user: String,
|
||||
@@ -103,6 +107,7 @@ object BundledRemoteConfig {
|
||||
search = "Search",
|
||||
movies = "Movies",
|
||||
tvShows = "TV Shows",
|
||||
genres = "Genres",
|
||||
tvCalendar = "TV Calendar",
|
||||
favourites = "Favourites",
|
||||
user = "User",
|
||||
@@ -233,6 +238,7 @@ internal fun validateRemoteConfig(document: MembyRemoteConfig, appVersion: Strin
|
||||
copy.navigation.search,
|
||||
copy.navigation.movies,
|
||||
copy.navigation.tvShows,
|
||||
copy.navigation.genres,
|
||||
copy.navigation.tvCalendar,
|
||||
copy.navigation.favourites,
|
||||
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.Favorite
|
||||
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.Info
|
||||
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),
|
||||
MOVIES("Movies", Icons.Default.Movie),
|
||||
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
|
||||
// has one — see [TvNavigationRail]'s calendarEnabled.
|
||||
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
|
||||
* 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 {
|
||||
it != BrowseDestination.PROFILES &&
|
||||
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.
|
||||
@@ -264,6 +272,7 @@ fun TvNavigationRail(
|
||||
activeUsername: String = "",
|
||||
activeProfileInitials: String = "",
|
||||
calendarEnabled: Boolean = false,
|
||||
genresEnabled: Boolean = true,
|
||||
) {
|
||||
var railHasFocus by remember { mutableStateOf(false) }
|
||||
val logoScale = remember { Animatable(0.72f) }
|
||||
@@ -334,12 +343,16 @@ fun TvNavigationRail(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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(
|
||||
painter = painterResource(R.drawable.emby_logo),
|
||||
painter = painterResource(R.drawable.memby_mark),
|
||||
contentDescription = "Memby",
|
||||
modifier = Modifier
|
||||
.width(32.dp)
|
||||
.height(27.dp)
|
||||
.size(30.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = logoScale.value
|
||||
scaleY = logoScale.value
|
||||
@@ -371,7 +384,9 @@ fun TvNavigationRail(
|
||||
// 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
|
||||
// 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
|
||||
// item beneath it changes the current destination. Leaving this to spatial
|
||||
// search allowed content behind the expanded rail to win occasionally.
|
||||
@@ -449,6 +464,7 @@ private fun configuredNavigationLabel(
|
||||
BrowseDestination.SEARCH -> labels.search
|
||||
BrowseDestination.MOVIES -> labels.movies
|
||||
BrowseDestination.SHOWS -> labels.tvShows
|
||||
BrowseDestination.GENRES -> labels.genres
|
||||
BrowseDestination.CALENDAR -> labels.tvCalendar
|
||||
BrowseDestination.FAVORITES -> labels.favourites
|
||||
BrowseDestination.PROFILES -> labels.user
|
||||
|
||||
@@ -2034,9 +2034,19 @@ private fun HomeScreen(
|
||||
}
|
||||
}
|
||||
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
|
||||
genreBrowseInitialCategoryId = null
|
||||
if (strandedOnDestination) {
|
||||
selectedDestination = BrowseDestination.HOME
|
||||
railFocusDestination = BrowseDestination.HOME
|
||||
}
|
||||
kotlinx.coroutines.delay(16L)
|
||||
requestFirstAvailableFocus(contentFocusRequester, navigationFocusRequester)
|
||||
}
|
||||
@@ -2558,6 +2568,7 @@ private fun HomeScreen(
|
||||
activeUsername = settings.username.orEmpty(),
|
||||
activeProfileInitials = settings.profileInitials,
|
||||
calendarEnabled = tvCalendarEnabled,
|
||||
genresEnabled = genreBrowserEnabled,
|
||||
)
|
||||
androidx.compose.foundation.layout.BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
@@ -2677,6 +2688,48 @@ private fun HomeScreen(
|
||||
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 ->
|
||||
GenreBrowseScreen(
|
||||
itemType = itemType,
|
||||
@@ -2701,6 +2754,7 @@ private fun HomeScreen(
|
||||
detailsAiringNotice = null
|
||||
detailsItem = item
|
||||
},
|
||||
onContentFocused = { navigationExpanded = false },
|
||||
onClose = {
|
||||
genreBrowseItemType = null
|
||||
genreBrowseInitialCategoryId = null
|
||||
@@ -4541,6 +4595,7 @@ internal fun homeRowsFor(
|
||||
// Search draws its own pane; the rail destinations that open an overlay have no
|
||||
// rows of their own either.
|
||||
BrowseDestination.SEARCH -> emptyList()
|
||||
BrowseDestination.GENRES -> emptyList()
|
||||
BrowseDestination.CALENDAR -> emptyList()
|
||||
BrowseDestination.PROFILES -> emptyList()
|
||||
BrowseDestination.SETTINGS -> emptyList()
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
package com.ponzischeme89.memby.ui.genre
|
||||
|
||||
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.border
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
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.items
|
||||
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.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.filled.AutoAwesome
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.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.PosterGridCard
|
||||
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.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyControlSurface
|
||||
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.MembySurfaceRaised
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun GenreDiscoveryStrip(
|
||||
@@ -183,6 +207,36 @@ private fun genreVisual(icon: GenreCategoryIcon): GenreVisual = when (icon) {
|
||||
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
|
||||
fun GenreBrowseScreen(
|
||||
itemType: String,
|
||||
@@ -197,162 +251,175 @@ fun GenreBrowseScreen(
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
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(
|
||||
key = "genre-browse-${itemType.lowercase()}",
|
||||
key = "genre-browse-${itemType.lowercase().ifEmpty { "all" }}",
|
||||
factory = remember(itemType) {
|
||||
GenreBrowseViewModelFactory(ServiceLocator.repository, itemType)
|
||||
},
|
||||
)
|
||||
val state by browseViewModel.state.collectAsStateWithLifecycle()
|
||||
val gridState = rememberLazyGridState()
|
||||
val tabState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val selectedCategory = remember(state.selectedCategoryId, itemType) {
|
||||
genreCategory(itemType, state.selectedCategoryId)
|
||||
}
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
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(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) {
|
||||
kotlinx.coroutines.delay(32L)
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
}
|
||||
BackHandler(onBack = onClose)
|
||||
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(MembySurface)) {
|
||||
val columns = when {
|
||||
maxWidth >= 1080.dp -> 7
|
||||
maxWidth >= 860.dp -> 6
|
||||
maxWidth >= 680.dp -> 5
|
||||
else -> 4
|
||||
// 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.
|
||||
BackHandler {
|
||||
if (contentHasFocus) {
|
||||
runCatching { contentFocusRequester.requestFocus() }
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
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)) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = horizontalPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = onClose,
|
||||
contentDescription = "Back to $mediaLabel",
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.focusProperties { left = navigationFocusRequester }
|
||||
.background(MembySurfaceRaised, RoundedCornerShape(10.dp)),
|
||||
) { focused ->
|
||||
Box(
|
||||
/** Moves focus into the grid, at the card this genre was left on. */
|
||||
fun enterGrid(): Boolean {
|
||||
val items = state.items
|
||||
if (items.isEmpty()) return false
|
||||
// Where this genre was left comes first, and the card a detail page was opened
|
||||
// from is the fallback. The other order goes stale: returnFocusItemId is not
|
||||
// cleared once its restore is done, so it would keep pulling every later press
|
||||
// back to a card the viewer has long since scrolled past.
|
||||
val remembered = focusedCardIndexes[shownCategoryId]?.takeIf { it in items.indices }
|
||||
?: returnFocusItemId?.let { id -> items.indexOfFirst { it.id == id }.takeIf { i -> i >= 0 } }
|
||||
?: 0
|
||||
gridEntryIndex = remembered
|
||||
scope.launch {
|
||||
// A card outside the composed window cannot be focused, so the grid is put
|
||||
// back where it was before its card is asked for — and the requester has to
|
||||
// have moved to that card first, which is one recomposition away.
|
||||
runCatching { gridState.scrollToItem(remembered) }
|
||||
repeat(3) {
|
||||
kotlinx.coroutines.delay(16L)
|
||||
if (runCatching { gridEntryFocusRequester.requestFocus() }.isSuccess) return@launch
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
Box(modifier.fillMaxSize().background(MembySurface)) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
GenreRail(
|
||||
categories = state.categories,
|
||||
activeCategoryId = activeCategoryId,
|
||||
navigationFocusRequester = navigationFocusRequester,
|
||||
activeFocusRequester = contentFocusRequester,
|
||||
onCategoryFocused = { id ->
|
||||
onContentFocused()
|
||||
activeCategoryId = id
|
||||
},
|
||||
onEnterContent = ::enterGrid,
|
||||
)
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(if (focused) Color.White else Color.Transparent, RoundedCornerShape(10.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.onFocusChanged { contentHasFocus = it.hasFocus },
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = null,
|
||||
tint = if (focused) MembySurface else Color.White,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
val horizontalPadding = 40.dp
|
||||
val spacing = 18.dp
|
||||
val columns = when {
|
||||
maxWidth >= 1000.dp -> 6
|
||||
maxWidth >= 820.dp -> 5
|
||||
maxWidth >= 620.dp -> 4
|
||||
else -> 3
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column {
|
||||
Text(
|
||||
"Browse $mediaLabel",
|
||||
color = Color.White,
|
||||
fontSize = 26.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
val cardWidth = ((maxWidth - horizontalPadding * 2 - spacing * (columns - 1)) / columns)
|
||||
.coerceAtLeast(104.dp)
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Spacer(Modifier.height(44.dp))
|
||||
Column(Modifier.padding(horizontal = horizontalPadding)) {
|
||||
Text(
|
||||
selectedCategory.label,
|
||||
color = MembyMutedText,
|
||||
fontSize = 14.sp,
|
||||
color = Color.White,
|
||||
fontSize = 34.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
LazyRow(
|
||||
state = tabState,
|
||||
contentPadding = PaddingValues(horizontal = horizontalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
rowItemsIndexed(state.categories, key = { _, category -> category.id }) { index, category ->
|
||||
val selected = category.id == state.selectedCategoryId
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = {
|
||||
if (!selected) {
|
||||
browseViewModel.selectCategory(category.id)
|
||||
}
|
||||
},
|
||||
contentDescription = "Browse ${category.label}",
|
||||
modifier = Modifier
|
||||
.then(if (selected) Modifier.focusRequester(contentFocusRequester) else Modifier)
|
||||
.focusProperties { if (index == 0) left = navigationFocusRequester }
|
||||
.background(
|
||||
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(
|
||||
category.label,
|
||||
color = when {
|
||||
focused -> MembySurface
|
||||
selected -> Color.White
|
||||
else -> MembyMutedText
|
||||
},
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.background(
|
||||
if (focused) Color.White else Color.Transparent,
|
||||
RoundedCornerShape(18.dp),
|
||||
mediaLabel,
|
||||
color = MembyQuietText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
.padding(horizontal = 17.dp, vertical = 9.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(18.dp))
|
||||
|
||||
val gridPadding = PaddingValues(
|
||||
start = horizontalPadding,
|
||||
end = horizontalPadding,
|
||||
bottom = 72.dp,
|
||||
)
|
||||
when {
|
||||
state.selectedCategoryId == null -> GenreMessage("Loading $mediaLabel…")
|
||||
state.isLoading && state.items.isEmpty() -> GenreMessage(
|
||||
"Loading ${selectedCategory.label}…",
|
||||
// Placeholders rather than a spinner over the page: the heading,
|
||||
// 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
|
||||
// lands.
|
||||
state.isLoading && state.items.isEmpty() -> GenrePlaceholderGrid(
|
||||
columns = columns,
|
||||
cardWidth = cardWidth,
|
||||
spacing = spacing,
|
||||
contentPadding = gridPadding,
|
||||
)
|
||||
state.errorMessage != null && state.items.isEmpty() -> GenreRetry(
|
||||
message = state.errorMessage.orEmpty(),
|
||||
onRetry = browseViewModel::retry,
|
||||
navigationFocusRequester = contentFocusRequester,
|
||||
)
|
||||
state.items.isEmpty() -> GenreMessage(
|
||||
if (selectedCategory.genres.isEmpty()) "No $mediaLabel were found."
|
||||
else "No ${selectedCategory.label.lowercase()} $mediaLabel were found.",
|
||||
if (selectedCategory.genres.isEmpty()) {
|
||||
"No ${mediaLabel.lowercase()} were found."
|
||||
} else {
|
||||
"No ${selectedCategory.label.lowercase()} ${mediaLabel.lowercase()} were found."
|
||||
},
|
||||
)
|
||||
else -> {
|
||||
LaunchedEffect(gridState, state.items.size, state.canLoadMore) {
|
||||
snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
|
||||
snapshotFlow {
|
||||
gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { last ->
|
||||
if (state.canLoadMore && last >= state.items.size - columns * 2) {
|
||||
@@ -363,13 +430,9 @@ fun GenreBrowseScreen(
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(
|
||||
start = horizontalPadding,
|
||||
end = horizontalPadding,
|
||||
bottom = 72.dp,
|
||||
),
|
||||
contentPadding = gridPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(22.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
itemsIndexed(
|
||||
@@ -386,7 +449,11 @@ fun GenreBrowseScreen(
|
||||
PosterGridCard(
|
||||
item = displayedItem,
|
||||
width = cardWidth,
|
||||
onFocused = { onItemFocused(displayedItem) },
|
||||
onFocused = {
|
||||
onContentFocused()
|
||||
shownCategoryId?.let { focusedCardIndexes[it] = index }
|
||||
onItemFocused(displayedItem)
|
||||
},
|
||||
onClick = { onItemSelected(displayedItem) },
|
||||
onLongClick = { onItemSelected(displayedItem) },
|
||||
modifier = Modifier
|
||||
@@ -397,20 +464,31 @@ fun GenreBrowseScreen(
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.then(
|
||||
if (index == gridEntryIndex) {
|
||||
Modifier.focusRequester(gridEntryFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusProperties {
|
||||
if (index % columns == 0) left = navigationFocusRequester
|
||||
// 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
|
||||
},
|
||||
)
|
||||
}
|
||||
if (state.isLoadingMore) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
GenreMessage("Loading more…")
|
||||
items(GENRE_PLACEHOLDER_ROWS * columns) {
|
||||
GenrePlaceholderCard(cardWidth)
|
||||
}
|
||||
} else if (state.errorMessage != null) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
GenreRetry(
|
||||
message = state.errorMessage.orEmpty(),
|
||||
onRetry = browseViewModel::retry,
|
||||
navigationFocusRequester = contentFocusRequester,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -420,18 +498,249 @@ 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenreRetry(message: String, onRetry: () -> Unit) {
|
||||
private fun GenreRetry(
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(36.dp),
|
||||
Modifier.fillMaxWidth().padding(40.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@@ -442,11 +751,14 @@ private fun GenreRetry(message: String, onRetry: () -> Unit) {
|
||||
contentDescription = "Try loading the genre again",
|
||||
modifier = Modifier
|
||||
.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" },
|
||||
) { focused ->
|
||||
) { _ ->
|
||||
Text(
|
||||
"Try again",
|
||||
color = Color.White,
|
||||
color = MembyAccentInk,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
)
|
||||
|
||||
@@ -47,12 +47,26 @@ class GenreBrowseViewModel(
|
||||
private var pageJob: Job? = null
|
||||
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) {
|
||||
val selected = categories.firstOrNull { it.id == categoryId } ?: categories.first()
|
||||
val current = state.value
|
||||
if (current.selectedCategoryId == selected.id && (current.items.isNotEmpty() || current.isLoading)) return
|
||||
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()
|
||||
val warming = warmJobs[selected.id]?.isActive == true
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedCategoryId = selected.id,
|
||||
@@ -64,7 +78,30 @@ class GenreBrowseViewModel(
|
||||
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() {
|
||||
@@ -147,19 +184,24 @@ class GenreBrowseViewModel(
|
||||
)
|
||||
}
|
||||
}.onSuccess { page ->
|
||||
_state.update { current ->
|
||||
if (current.selectedCategoryId != category.id || current.readOffset != page.offset) {
|
||||
return@update current
|
||||
}
|
||||
val read = page.offset + page.items.size
|
||||
val items = (current.items + page.items).distinctBy(BaseItem::id)
|
||||
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 ->
|
||||
if (current.selectedCategoryId != category.id || current.readOffset != page.offset) {
|
||||
return@update current
|
||||
}
|
||||
current.copy(
|
||||
items = items,
|
||||
readOffset = read,
|
||||
|
||||
@@ -44,6 +44,15 @@ enum class GenreCategoryIcon {
|
||||
|
||||
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(
|
||||
id = ALL_MEDIA_CATEGORY_ID,
|
||||
label = "All",
|
||||
@@ -87,8 +96,9 @@ private val realityCategory = GenreCategory(
|
||||
)
|
||||
|
||||
fun genreCategories(itemType: String): List<GenreCategory> =
|
||||
if (itemType.equals("Series", ignoreCase = true)) coreGenreCategories + realityCategory
|
||||
else coreGenreCategories
|
||||
if (itemType.equals("Movie", ignoreCase = true)) coreGenreCategories
|
||||
// Reality is a television shelf, so it is offered wherever series can appear.
|
||||
else coreGenreCategories + realityCategory
|
||||
|
||||
fun genreCategoryTabs(itemType: String): List<GenreCategory> =
|
||||
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 =
|
||||
genreCategoryTabs(itemType).firstOrNull { it.id == id } ?: genreCategoryTabs(itemType).first()
|
||||
|
||||
fun allMediaLabel(itemType: String): String =
|
||||
if (itemType.equals("Series", ignoreCase = true)) "All TV shows" else "All Movies"
|
||||
fun allMediaLabel(itemType: String): String = when {
|
||||
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(
|
||||
userData = (userData ?: UserItemData()).copy(isFavorite = favourite),
|
||||
|
||||
@@ -580,6 +580,12 @@ class PlayerActivity : ComponentActivity() {
|
||||
?: intent.getBooleanExtra(EXTRA_SKIP_INTRO, false)
|
||||
endCreditsAvailable = savedInstanceState?.getBoolean(STATE_END_CREDITS)
|
||||
?: 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}")
|
||||
|
||||
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) {
|
||||
showPlaybackLoading()
|
||||
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
|
||||
// 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.
|
||||
//
|
||||
// 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(
|
||||
intent(
|
||||
this@PlayerActivity,
|
||||
@@ -3126,7 +3144,6 @@ class PlayerActivity : ComponentActivity() {
|
||||
backdropUrl = pick.backdropUrl,
|
||||
),
|
||||
)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4980,6 +4997,12 @@ class PlayerActivity : ComponentActivity() {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -5315,6 +5338,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
private const val STATE_EPISODE_CODE = "state_episode_code"
|
||||
private const val STATE_RUNTIME_MS = "state_runtime_ms"
|
||||
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 SUBTITLE_SIZE_KEY = "subtitle_size"
|
||||
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.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NavigationRailTest {
|
||||
@@ -26,4 +27,24 @@ class NavigationRailTest {
|
||||
assertEquals(BrowseDestination.entries.size - 1, items.size)
|
||||
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)
|
||||
}
|
||||
|
||||
@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
|
||||
fun `favourite overrides update a paged card copy immediately`() {
|
||||
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
|
||||
+16
-112
@@ -26,8 +26,7 @@ SSH performs the password prompt directly. The password is never read or stored
|
||||
by this script.
|
||||
|
||||
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
|
||||
published, and the running Android builder container and image are left untouched.
|
||||
Use -SkipAppRelease for an admin/server-only deployment: no APK is built or published.
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy-server.ps1
|
||||
@@ -48,7 +47,7 @@ published, and the running Android builder container and image are left untouche
|
||||
.\deploy-server.ps1 -EstimateOnly
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy-server.ps1 -SkipAppRelease -SkipBuilder
|
||||
.\deploy-server.ps1 -SkipAppRelease
|
||||
#>
|
||||
|
||||
#Requires -Version 7.2
|
||||
@@ -88,11 +87,6 @@ param(
|
||||
[Parameter()]
|
||||
[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()]
|
||||
[Alias('m')]
|
||||
[switch] $MandatoryUpdate,
|
||||
@@ -427,9 +421,6 @@ function Write-Banner {
|
||||
if ($quietDeployment) {
|
||||
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 } |
|
||||
Measure-Object -Sum).Sum
|
||||
$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/dist', '--exclude', 'admin-ui/dist/*',
|
||||
'-C', $RepositoryDirectory,
|
||||
'server', 'admin-ui', 'builder', 'docker-compose.yml', '.env.example',
|
||||
'builder.env.example'
|
||||
'server', 'admin-ui', 'docker-compose.yml', '.env.example'
|
||||
)
|
||||
if ($ReleaseDirectory) {
|
||||
$arguments += @(
|
||||
@@ -789,13 +779,8 @@ try {
|
||||
(Join-Path $checkoutDirectory 'admin-ui/Dockerfile'),
|
||||
(Join-Path $checkoutDirectory 'admin-ui/package.json'),
|
||||
(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 '.env.example'),
|
||||
(Join-Path $checkoutDirectory 'builder.env.example')
|
||||
(Join-Path $checkoutDirectory '.env.example')
|
||||
)
|
||||
foreach ($requiredPath in $requiredPaths) {
|
||||
if (-not (Test-Path -LiteralPath $requiredPath)) {
|
||||
@@ -804,7 +789,6 @@ try {
|
||||
}
|
||||
Write-Detail 'server/ build context'
|
||||
Write-Detail 'admin-ui/ build context'
|
||||
Write-Detail 'builder/ release toolchain'
|
||||
Write-Detail 'docker-compose.yml'
|
||||
Write-Detail '.env.example'
|
||||
$releaseDirectory = ''
|
||||
@@ -914,7 +898,6 @@ set -eu
|
||||
destination='__DESTINATION__'
|
||||
health_timeout=__HEALTH_TIMEOUT__
|
||||
publish_release=__PUBLISH_RELEASE__
|
||||
skip_builder=__SKIP_BUILDER__
|
||||
mandatory_update=__MANDATORY_UPDATE__
|
||||
quiet_deployment=__QUIET_DEPLOYMENT__
|
||||
colour_output=__COLOUR_OUTPUT__
|
||||
@@ -950,14 +933,7 @@ failure() {
|
||||
}
|
||||
|
||||
start_restored_stack() {
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
# 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() {
|
||||
@@ -976,12 +952,7 @@ rollback() {
|
||||
detail "Stopping the incomplete application release"
|
||||
(
|
||||
cd "$destination"
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -1090,9 +1061,6 @@ test -f "$staging/docker-compose.yml"
|
||||
test -f "$staging/server/Dockerfile"
|
||||
test -f "$staging/admin-ui/Dockerfile"
|
||||
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
|
||||
test -f "$staging/release/version.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
|
||||
# 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')
|
||||
# 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.
|
||||
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.
|
||||
@@ -1168,44 +1136,18 @@ if [ ! -s "$release_token_file" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
missing_builder_secrets=0
|
||||
check_required_secret() {
|
||||
required_secret="$1"
|
||||
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
|
||||
if [ ! -s "$release_token_file" ]; then
|
||||
failure "Required secret is missing or empty: $release_token_file"
|
||||
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
|
||||
fi
|
||||
# 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
|
||||
# would be present but unreadable in those containers. The 0700 parent prevents every
|
||||
# other NAS account from traversing to them; 0444 makes only the read-only secret mounts
|
||||
# usable by the non-root container processes and also prevents accidental host writes.
|
||||
# deliberately runs as uid 65532, so a file created as the SSH user with 0600 would be
|
||||
# present but unreadable in that container. The 0700 parent prevents every other NAS
|
||||
# account from traversing to it; 0444 makes only the read-only secret mount usable by the
|
||||
# non-root container process and also prevents accidental host writes.
|
||||
chmod 700 "$secrets_dir"
|
||||
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'
|
||||
if [ -n "$previous_password" ] && [ "$previous_password" != "$new_password" ]; then
|
||||
failure 'POSTGRES_PASSWORD differs from the deployed value'
|
||||
@@ -1256,34 +1198,15 @@ step 'Pulling PostgreSQL and Redis'
|
||||
)
|
||||
success 'Dependency images are ready'
|
||||
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
step 'Building the gateway and admin console; reusing the Android builder'
|
||||
else
|
||||
step 'Building the gateway, admin console and Android builder'
|
||||
fi
|
||||
step 'Building the gateway and admin console'
|
||||
(
|
||||
cd "$staging"
|
||||
# `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
|
||||
# continues to serve the previous console image (and its old route configuration).
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
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; Android builder container retained'
|
||||
else
|
||||
success 'Gateway, admin console and Android builder images built'
|
||||
fi
|
||||
success 'Gateway and admin console images built'
|
||||
|
||||
step 'Activating the release'
|
||||
rm -rf -- "$backup"
|
||||
@@ -1292,20 +1215,11 @@ if [ -e "$destination" ] || [ -L "$destination" ]; then
|
||||
# Compose projects created by older releases may use a different project
|
||||
# name. Stop them from their original directory before moving it so their
|
||||
# published ports (especially 32768) are released for the new stack.
|
||||
if [ "$skip_builder" -eq 1 ]; then
|
||||
detail 'Stopping only the gateway and admin console; preserving builder, database and cache'
|
||||
(
|
||||
cd "$destination"
|
||||
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
|
||||
success 'Previous Compose application stopped'
|
||||
fi
|
||||
@@ -1318,17 +1232,12 @@ success 'Release activated'
|
||||
|
||||
step 'Starting the Compose stack'
|
||||
cd "$destination"
|
||||
if [ "$skip_builder" -eq 1 ]; 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
|
||||
if ! docker compose up -d --no-build --remove-orphans; then
|
||||
failure 'Compose could not start the complete application'
|
||||
detail 'Container state before rollback:'
|
||||
docker compose ps --all || true
|
||||
detail 'Gateway and builder logs before rollback:'
|
||||
docker compose logs --no-color --tail 100 server memby-builder || true
|
||||
detail 'Gateway logs before rollback:'
|
||||
docker compose logs --no-color --tail 100 server || true
|
||||
exit 1
|
||||
fi
|
||||
success 'Compose start command completed'
|
||||
@@ -1338,7 +1247,6 @@ wait_for_service postgres
|
||||
wait_for_service redis
|
||||
wait_for_service memby-admin
|
||||
wait_for_service server
|
||||
wait_for_service memby-builder
|
||||
|
||||
published_address=$(docker compose port server 32768 | head -n 1)
|
||||
actual_port=${published_address##*:}
|
||||
@@ -1418,10 +1326,6 @@ success 'Memby gateway: https://mserver.sublogue.com'
|
||||
'__PUBLISH_RELEASE__',
|
||||
$(if ($SkipAppRelease) { '0' } else { '1' })
|
||||
)
|
||||
$remoteCommand = $remoteCommand.Replace(
|
||||
'__SKIP_BUILDER__',
|
||||
$(if ($SkipBuilder) { '1' } else { '0' })
|
||||
)
|
||||
$remoteCommand = $remoteCommand.Replace(
|
||||
'__MANDATORY_UPDATE__',
|
||||
$(if ($mandatoryRelease) { '1' } else { '0' })
|
||||
|
||||
@@ -57,9 +57,6 @@ services:
|
||||
# keeps the release credential out of `docker inspect` while preserving the
|
||||
# existing CI/backend publish API.
|
||||
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
|
||||
# films appearing weekly ride along.
|
||||
MEMBY_SYNC_INTERVAL: "${MEMBY_SYNC_INTERVAL:-1h}"
|
||||
@@ -118,51 +115,6 @@ services:
|
||||
retries: 3
|
||||
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.
|
||||
#
|
||||
# Deliberately not published. The household's reverse proxy sends one hostname to the
|
||||
@@ -218,16 +170,7 @@ volumes:
|
||||
memby-postgres:
|
||||
memby-releases:
|
||||
memby-logs:
|
||||
memby-gradle-cache:
|
||||
|
||||
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:
|
||||
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/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
|
||||
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("GET /admin/api/media-reports", s.adminAuth(s.handleAdminMediaReports))
|
||||
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)
|
||||
offset := queryOffset(r, "offset")
|
||||
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")
|
||||
return
|
||||
}
|
||||
@@ -145,8 +145,15 @@ func (s *Server) handleBrowseItems(
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// genreItemType keeps the old mixed search as the default for Search, while the Movies
|
||||
// and TV Series destinations can ask for a shelf that never crosses media types.
|
||||
// genreItemType keeps the mixed shelf as the default, which is what the Search chips and
|
||||
// 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) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
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")
|
||||
return
|
||||
}
|
||||
// Version the entry when the detail contract grows so older cached payloads cannot
|
||||
// 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 {
|
||||
if raw, err := s.cache.Get(ctx, itemDetailKey(sess.EmbyUserID, itemID)); err == nil {
|
||||
w.Header().Set("X-Memby-Cache", "hit")
|
||||
writeRaw(w, http.StatusOK, raw)
|
||||
return
|
||||
}
|
||||
|
||||
item, err := s.emby.Item(ctx, credentials(sess), itemID, fieldsDetail)
|
||||
item, err := s.detailItem(ctx, sess, itemID)
|
||||
if err != nil {
|
||||
s.writeUpstreamError(ctx, w, err, "could not load the item")
|
||||
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
|
||||
// after a second request. Anything not yet stored still arrives on /ratings.
|
||||
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 {
|
||||
s.loggerFor(ctx).Warn("item cache write failed", "error", err)
|
||||
}
|
||||
w.Header().Set("X-Memby-Cache", "miss")
|
||||
writeRaw(w, http.StatusOK, item)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// handleSeasonFinale verifies an episode against Sonarr's complete season, including
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/cache"
|
||||
"github.com/ponzischeme89/memby/server/internal/recommend"
|
||||
"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)
|
||||
}
|
||||
|
||||
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,
|
||||
AvailableMinutes: req.AvailableMinutes,
|
||||
// The one non-deterministic thing about the feature, named in one place.
|
||||
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 {
|
||||
// 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.
|
||||
@@ -67,24 +82,54 @@ func (s *Server) handleMagic(w http.ResponseWriter, r *http.Request, sess store.
|
||||
return
|
||||
}
|
||||
|
||||
item, err := s.emby.Item(ctx, credentials(sess), selection.Item.ID, fieldsDetail)
|
||||
item, err := s.detailItem(ctx, sess, selection.ItemID)
|
||||
if err != nil {
|
||||
s.writeUpstreamError(ctx, w, err, "could not load the suggestion")
|
||||
return
|
||||
}
|
||||
decorated := []json.RawMessage{item}
|
||||
s.decorateItemRatings(ctx, decorated)
|
||||
|
||||
s.loggerFor(ctx).Info("magic picked",
|
||||
"item", selection.Item.ID,
|
||||
"title", selection.Item.Name,
|
||||
"item", selection.ItemID,
|
||||
"title", selection.Title,
|
||||
"score", selection.Score,
|
||||
"pool", selection.PoolSize,
|
||||
"signals", strings.Join(selection.Signals, ","),
|
||||
)
|
||||
|
||||
writeJSON(w, http.StatusOK, magicResponse{
|
||||
Item: decorated[0],
|
||||
Item: item,
|
||||
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.
|
||||
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).
|
||||
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
|
||||
// normal request and so needs more headroom than UpstreamTimeout.
|
||||
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 string
|
||||
// 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
|
||||
// from AdminToken so a compromised build runner cannot change maintenance settings.
|
||||
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 time.Duration
|
||||
@@ -221,6 +224,7 @@ func Load() (Config, error) {
|
||||
SessionIdleExpiry: duration("MEMBY_SESSION_IDLE_EXPIRY", 90*24*time.Hour),
|
||||
RecommendTTL: duration("MEMBY_RECOMMEND_TTL", 24*time.Hour),
|
||||
RecommendTimeout: duration("MEMBY_RECOMMEND_TIMEOUT", 60*time.Second),
|
||||
MagicPoolTTL: duration("MEMBY_MAGIC_POOL_TTL", 2*time.Hour),
|
||||
RecommendationWeights: strings.TrimSpace(
|
||||
os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"),
|
||||
),
|
||||
@@ -231,7 +235,6 @@ func Load() (Config, error) {
|
||||
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
|
||||
ReleaseDir: env("MEMBY_RELEASE_DIR", "/data/releases"),
|
||||
ReleasePublishToken: releasePublishToken,
|
||||
ReleaseBuilderURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_RELEASE_BUILDER_URL")), "/"),
|
||||
SyncInterval: duration("MEMBY_SYNC_INTERVAL", time.Hour),
|
||||
SyncTimeout: duration("MEMBY_SYNC_TIMEOUT", 30*time.Minute),
|
||||
SyncOnStart: boolean("MEMBY_SYNC_ON_START", false),
|
||||
|
||||
@@ -30,6 +30,14 @@ const (
|
||||
// nothing genuinely unsuitable can be drawn.
|
||||
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"
|
||||
// is most of what somebody means by the button.
|
||||
magicUnwatchedBonus = 1.4
|
||||
@@ -63,13 +71,47 @@ type MagicOptions struct {
|
||||
// draw is deterministic under test — and so that the *only* non-deterministic thing
|
||||
// about this feature sits in one named parameter.
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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 {
|
||||
Item Item
|
||||
ItemID string
|
||||
Title string
|
||||
// Reasons is viewer-facing wording from the same explanation layer a detail page uses.
|
||||
Reasons []string
|
||||
// Signals is why this title was *eligible*, in machine-readable slugs, so the choice
|
||||
@@ -82,38 +124,70 @@ type MagicSelection struct {
|
||||
PoolSize int
|
||||
}
|
||||
|
||||
// MagicPick gathers the signals and draws. Errors only when the profile cannot be built at
|
||||
// all and the catalogue is empty with it — every lesser failure degrades, on the principle
|
||||
// [Engine.RelatedTo] already applies: a button that sometimes does nothing is worse than one
|
||||
// that occasionally picks less well.
|
||||
func (e *Engine) MagicPick(
|
||||
// MagicPool does the expensive half: the taste profile, the candidate query and the
|
||||
// weighing. Nothing about it is request-scoped, which is what makes it safe to keep.
|
||||
//
|
||||
// It never errors. A profile that cannot be built costs the weighting and not the button,
|
||||
// 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,
|
||||
cred emby.Credentials,
|
||||
opts MagicOptions,
|
||||
) (MagicSelection, bool) {
|
||||
if opts.Now.IsZero() {
|
||||
opts.Now = e.now()
|
||||
now time.Time,
|
||||
) []MagicCandidate {
|
||||
if now.IsZero() {
|
||||
now = e.now()
|
||||
}
|
||||
|
||||
history, favorites, err := e.gatherSignals(ctx, cred)
|
||||
if err != nil {
|
||||
// A profile that cannot be built costs the weighting, not the button. What is left
|
||||
// is an unweighted draw over the catalogue, which is still "put something on".
|
||||
// What is left is an unweighted draw over the catalogue, which is still "put
|
||||
// something on".
|
||||
e.log.Warn("magic signals unavailable; drawing without taste", "error", err)
|
||||
}
|
||||
profile := BuildProfile(history, favorites)
|
||||
|
||||
candidates := e.magicCandidates(ctx, cred, profile)
|
||||
if len(candidates) == 0 {
|
||||
return MagicSelection{}, false
|
||||
return nil
|
||||
}
|
||||
|
||||
selection, ok := ChooseMagic(profile, candidates, opts)
|
||||
if !ok {
|
||||
return MagicSelection{}, false
|
||||
pool := make([]MagicCandidate, 0, len(candidates))
|
||||
byID := make(map[string]Item, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
if candidate.ID == "" || byID[candidate.ID].ID != "" {
|
||||
continue
|
||||
}
|
||||
selection.Reasons = Why(profile, selection.Item, 2)
|
||||
return selection, true
|
||||
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,
|
||||
})
|
||||
}
|
||||
sortMagicPool(pool)
|
||||
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
|
||||
@@ -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
|
||||
// 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.
|
||||
func ChooseMagic(profile Profile, candidates []Item, opts MagicOptions) (MagicSelection, bool) {
|
||||
func ChooseMagic(candidates []MagicCandidate, opts MagicOptions) (MagicSelection, bool) {
|
||||
excluded := map[string]bool{}
|
||||
for _, id := range opts.ExcludeIDs {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
@@ -183,32 +257,28 @@ func ChooseMagic(profile Profile, candidates []Item, opts MagicOptions) (MagicSe
|
||||
}
|
||||
}
|
||||
|
||||
type scored struct {
|
||||
item Item
|
||||
score float64
|
||||
signals []string
|
||||
}
|
||||
pool := make([]scored, 0, len(candidates))
|
||||
pool := make([]MagicCandidate, 0, len(candidates))
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.ID == "" || excluded[candidate.ID] || seen[candidate.ID] {
|
||||
if candidate.ItemID == "" || excluded[candidate.ItemID] || seen[candidate.ItemID] {
|
||||
continue
|
||||
}
|
||||
seen[candidate.ID] = true
|
||||
score, signals := magicScore(profile, candidate, opts)
|
||||
pool = append(pool, scored{item: candidate, score: score, signals: signals})
|
||||
seen[candidate.ItemID] = true
|
||||
// Only the terms that belong to this press: everything the profile had to say is
|
||||
// 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 {
|
||||
return MagicSelection{}, false
|
||||
}
|
||||
|
||||
sort.SliceStable(pool, func(i, j int) bool {
|
||||
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
|
||||
})
|
||||
sortMagicPool(pool)
|
||||
if len(pool) > MagicPoolLimit {
|
||||
pool = pool[:MagicPoolLimit]
|
||||
}
|
||||
@@ -226,27 +296,42 @@ func ChooseMagic(profile Profile, candidates []Item, opts MagicOptions) (MagicSe
|
||||
for index, entry := range pool {
|
||||
cumulative += float64(len(pool) - index)
|
||||
if target < cumulative {
|
||||
return MagicSelection{
|
||||
Item: entry.item,
|
||||
Signals: entry.signals,
|
||||
Score: entry.score,
|
||||
PoolSize: len(pool),
|
||||
}, true
|
||||
return magicSelection(entry, len(pool)), true
|
||||
}
|
||||
}
|
||||
last := pool[len(pool)-1]
|
||||
return MagicSelection{
|
||||
Item: last.item,
|
||||
Signals: last.signals,
|
||||
Score: last.score,
|
||||
PoolSize: len(pool),
|
||||
}, true
|
||||
return magicSelection(pool[len(pool)-1], len(pool)), true
|
||||
}
|
||||
|
||||
// magicScore sums the stated terms 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.
|
||||
func magicScore(profile Profile, item Item, opts MagicOptions) (float64, []string) {
|
||||
func magicSelection(entry MagicCandidate, poolSize int) MagicSelection {
|
||||
return MagicSelection{
|
||||
ItemID: entry.ItemID,
|
||||
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)
|
||||
score := profile.Affinity(item)
|
||||
if score > 0 {
|
||||
@@ -266,26 +351,31 @@ func magicScore(profile Profile, item Item, opts MagicOptions) (float64, []strin
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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