This commit is contained in:
ponzischeme89
2026-08-17 13:13:10 +12:00
parent 12b27c77c3
commit 43ab18854c
39 changed files with 1707 additions and 1528 deletions
+2 -3
View File
@@ -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
+10 -13
View File
@@ -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
+170 -131
View File
@@ -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.
[![Licence: GPL v2](https://img.shields.io/badge/licence-GPL--2.0-green.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-Android%20TV-green.svg)](#build--install)
[![Min SDK](https://img.shields.io/badge/minSdk-23-green.svg)](#build--install)
[![Kotlin](https://img.shields.io/badge/Kotlin-Compose%20for%20TV-green.svg)](#tech-stack)
[![Gateway](https://img.shields.io/badge/gateway-Go%20%2B%20Postgres%20%2B%20Redis-green.svg)](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
-11
View File
@@ -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 {
+2 -129
View File
@@ -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}
</>
);
}
+1 -1
View File
@@ -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,
)
}
}
@@ -419,19 +497,250 @@ 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))
}
}
}
}
-10
View File
@@ -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=
-4
View File
@@ -1,4 +0,0 @@
**
!Dockerfile
!release.sh
!controller.go
-54
View File
@@ -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"]
-91
View File
@@ -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.
-203
View File
@@ -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})
}
-18
View File
@@ -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)
}
}
-210
View File
@@ -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
View File
@@ -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
View File
@@ -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"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

-2
View File
@@ -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))
+10 -3
View File
@@ -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 "":
+29 -8
View File
@@ -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
+52 -7
View File
@@ -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
}
-86
View File
@@ -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)
}
}
+6
View File
@@ -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 }
+7 -4
View File
@@ -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),
+159 -69
View File
@@ -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,28 +351,33 @@ 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
}
// 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
// an error: it simply cannot earn the recently-added bonus.
func daysSince(value string, now time.Time) (int, bool) {
+168
View File
@@ -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
}