diff --git a/.gitignore b/.gitignore index 6c4fde5..c6be924 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index cf83623..05e4682 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `.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 diff --git a/README.md b/README.md index 58c3b3d..1ee2937 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,68 @@ -# Memby (Android TV) +
-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. +Memby + +# 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) +
+ +--- + +## 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\\\\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\\\\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-.apk` and -`/data/releases/memby-.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-.apk` and `/data/releases/memby-.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,17 +271,16 @@ 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 -latest.json the update manifest the app polls -memby-0.1.54.apk the build +index.html the page people are sent to +latest.json the update manifest the app polls +memby-0.1.54.apk the build memby-0.1.54.apk.sha256 the SHA-256 checksum -LICENSE GNU GPL v2 terms -NOTICE copyright and third-party acknowledgements +LICENSE GNU GPL v2 terms +NOTICE copyright and third-party acknowledgements ``` Copy those to the folder the NAS serves. Old APKs can stay alongside — only `latest.json` @@ -252,8 +291,8 @@ decides what the app offers, so rolling back is editing one file. The gateway checks the running app version on launch and returns an optional or mandatory 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 diff --git a/admin-ui/src/api/types.ts b/admin-ui/src/api/types.ts index a282639..6bb07eb 100644 --- a/admin-ui/src/api/types.ts +++ b/admin-ui/src/api/types.ts @@ -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 { diff --git a/admin-ui/src/pages/Updates.tsx b/admin-ui/src/pages/Updates.tsx index 81fdf83..e51dbcf 100644 --- a/admin-ui/src/pages/Updates.tsx +++ b/admin-ui/src/pages/Updates.tsx @@ -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(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(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('/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) => setDraft((current) => (current ? { ...current, ...next } : current)); - const startRelease = () => - run('release', async () => { - const next = await wrap( - () => - api.post('/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 ( <> - - - {builder?.state ?? 'checking'}} - footer={ - - } - > -
- - setReleaseTag(event.target.value)} - /> - - - setReleaseNotes(event.target.value)} - /> - -
- - {builder?.message ? {builder.message} : null} - {builder?.logs?.length ?
{builder.logs.join('\n')}
: null} - - Command-line fallback: {builder?.fallback ?? 'docker compose run --rm --build memby-builder release'} - -
{loading || !draft ? ( @@ -303,17 +187,6 @@ export function UpdatesPage() { onCancel={() => setConfirming(false)} /> ) : null} - - {releaseConfirming ? ( - void startRelease()} - onCancel={() => setReleaseConfirming(false)} - /> - ) : null} ); } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2ee87c8..89919b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index dff6695..517b083 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfig.kt b/app/src/main/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfig.kt index 657e082..83ebbae 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfig.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remoteconfig/MembyRemoteConfig.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index c71eed5..525073c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -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 = +internal fun navigationRailItems( + calendarEnabled: Boolean, + genresEnabled: Boolean = true, +): List = 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index bebc241..f71251d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt index 741bec9..eb91ec8 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseScreen.kt @@ -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,221 +251,247 @@ 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() } + val focusedCardIndexes = remember(itemType) { mutableMapOf() } + 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( - Modifier - .fillMaxSize() - .background(if (focused) Color.White else Color.Transparent, RoundedCornerShape(10.dp)), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = null, - tint = if (focused) MembySurface else Color.White, - modifier = Modifier.size(22.dp), - ) - } - } - Spacer(Modifier.width(14.dp)) - Column { - Text( - "Browse $mediaLabel", - color = Color.White, - fontSize = 26.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - selectedCategory.label, - color = MembyMutedText, - fontSize = 14.sp, - ) - } + /** 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 + } - Spacer(Modifier.height(16.dp)) - LazyRow( - state = tabState, - contentPadding = PaddingValues(horizontal = horizontalPadding), - horizontalArrangement = Arrangement.spacedBy(8.dp), + 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 + .weight(1f) + .fillMaxHeight() + .onFocusChanged { contentHasFocus = it.hasFocus }, ) { - 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 -> + 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 + } + 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( - 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), - ) - .padding(horizontal = 17.dp, vertical = 9.dp), + selectedCategory.label, + color = Color.White, + fontSize = 34.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + mediaLabel, + color = MembyQuietText, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, ) } - } - } - Spacer(Modifier.height(18.dp)) + Spacer(Modifier.height(18.dp)) - when { - state.selectedCategoryId == null -> GenreMessage("Loading $mediaLabel…") - state.isLoading && state.items.isEmpty() -> GenreMessage( - "Loading ${selectedCategory.label}…", - ) - state.errorMessage != null && state.items.isEmpty() -> GenreRetry( - message = state.errorMessage.orEmpty(), - onRetry = browseViewModel::retry, - ) - state.items.isEmpty() -> GenreMessage( - if (selectedCategory.genres.isEmpty()) "No $mediaLabel were found." - else "No ${selectedCategory.label.lowercase()} $mediaLabel were found.", - ) - else -> { - LaunchedEffect(gridState, state.items.size, state.canLoadMore) { - snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } - .distinctUntilChanged() - .collect { last -> - if (state.canLoadMore && last >= state.items.size - columns * 2) { - browseViewModel.loadMore() - } - } - } - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - state = gridState, - contentPadding = PaddingValues( - start = horizontalPadding, - end = horizontalPadding, - bottom = 72.dp, - ), - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalArrangement = Arrangement.spacedBy(20.dp), - modifier = Modifier.fillMaxSize(), - ) { - itemsIndexed( - state.items, - key = { _, item -> item.id }, - contentType = { _, _ -> "genre-poster" }, - ) { index, item -> - val favourite = favouriteStates[item.id] - val displayedItem = if (favourite == null || favourite == item.isFavorite) { - item + val gridPadding = PaddingValues( + start = horizontalPadding, + end = horizontalPadding, + bottom = 72.dp, + ) + when { + // 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.lowercase()} were found." } else { - item.withFavourite(favourite) + "No ${selectedCategory.label.lowercase()} ${mediaLabel.lowercase()} were found." + }, + ) + else -> { + LaunchedEffect(gridState, state.items.size, state.canLoadMore) { + snapshotFlow { + gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + } + .distinctUntilChanged() + .collect { last -> + if (state.canLoadMore && last >= state.items.size - columns * 2) { + browseViewModel.loadMore() + } + } } - PosterGridCard( - item = displayedItem, - width = cardWidth, - onFocused = { onItemFocused(displayedItem) }, - onClick = { onItemSelected(displayedItem) }, - onLongClick = { onItemSelected(displayedItem) }, - modifier = Modifier - .then( - if (item.id == returnFocusItemId) { - Modifier.focusRequester(returnFocusRequester) - } else { - Modifier + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + state = gridState, + contentPadding = gridPadding, + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalArrangement = Arrangement.spacedBy(22.dp), + modifier = Modifier.fillMaxSize(), + ) { + itemsIndexed( + state.items, + key = { _, item -> item.id }, + contentType = { _, _ -> "genre-poster" }, + ) { index, item -> + val favourite = favouriteStates[item.id] + val displayedItem = if (favourite == null || favourite == item.isFavorite) { + item + } else { + item.withFavourite(favourite) + } + PosterGridCard( + item = displayedItem, + width = cardWidth, + onFocused = { + onContentFocused() + shownCategoryId?.let { focusedCardIndexes[it] = index } + onItemFocused(displayedItem) }, + onClick = { onItemSelected(displayedItem) }, + onLongClick = { onItemSelected(displayedItem) }, + modifier = Modifier + .then( + if (item.id == returnFocusItemId) { + Modifier.focusRequester(returnFocusRequester) + } else { + Modifier + }, + ) + .then( + if (index == gridEntryIndex) { + Modifier.focusRequester(gridEntryFocusRequester) + } else { + Modifier + }, + ) + .focusProperties { + // Left out of the first column is the way + // back to the genre this grid belongs to, + // which is where that requester is attached. + if (index % columns == 0) left = contentFocusRequester + }, ) - .focusProperties { - if (index % columns == 0) left = navigationFocusRequester - }, - ) - } - if (state.isLoadingMore) { - item(span = { GridItemSpan(maxLineSpan) }) { - GenreMessage("Loading more…") - } - } else if (state.errorMessage != null) { - item(span = { GridItemSpan(maxLineSpan) }) { - GenreRetry( - message = state.errorMessage.orEmpty(), - onRetry = browseViewModel::retry, - ) + } + if (state.isLoadingMore) { + 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, + ) + } + } } } } @@ -421,17 +501,246 @@ fun GenreBrowseScreen( } } +/** + * The secondary rail. + * + * Its separation from the launcher's own rail is tonal rather than structural — a raised + * translucent surface fading into the content background, with a hairline where the two + * meet. That is enough to read the hierarchy at three metres without a second black + * column making the screen look like two applications side by side. + */ +@Composable +internal fun GenreRail( + categories: List, + 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), ) diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt index 3897194..44bdcf9 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreBrowseViewModel.kt @@ -47,12 +47,26 @@ class GenreBrowseViewModel( private var pageJob: Job? = null private val categoryPages = mutableMapOf() + /** + * 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() + 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 -> + val read = page.offset + page.items.size + val existing = if (page.offset == 0) emptyList() else categoryPages[category.id]?.items.orEmpty() + val items = (existing + page.items).distinctBy(BaseItem::id) + val canLoadMore = hasMoreGenreItems( + loaded = read, + total = page.total, + lastPageSize = page.items.size, + pageSize = GENRE_PAGE_SIZE, + ) + // Filed under its genre whether or not that genre is the one on screen. A warm + // for the category below the selection has no state to write and exists only to + // leave this behind; keeping the cache write inside the state guard is what made + // it fetch a page and then throw it away. + categoryPages[category.id] = CachedCategoryPage(items, read, canLoadMore) _state.update { current -> 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 canLoadMore = hasMoreGenreItems( - loaded = read, - total = page.total, - lastPageSize = page.items.size, - pageSize = GENRE_PAGE_SIZE, - ) - categoryPages[category.id] = CachedCategoryPage(items, read, canLoadMore) current.copy( items = items, readOffset = read, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt index f844288..7e720e8 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/genre/GenreCategories.kt @@ -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 = - 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 = listOf(allMediaCategory.copy(label = allMediaLabel(itemType))) + genreCategories(itemType) @@ -96,8 +106,35 @@ fun genreCategoryTabs(itemType: String): List = 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, selectedId: String?): List { + 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), diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 4cd2df6..41fd11b 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -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" diff --git a/app/src/main/res/drawable-nodpi/memby_mark.png b/app/src/main/res/drawable-nodpi/memby_mark.png new file mode 100644 index 0000000..1d13dbc Binary files /dev/null and b/app/src/main/res/drawable-nodpi/memby_mark.png differ diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/LeftRailScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/LeftRailScreenshotTest.kt new file mode 100644 index 0000000..a51a764 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/LeftRailScreenshotTest.kt @@ -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() } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/NavigationRailTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/NavigationRailTest.kt index fff2702..ad33b20 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/NavigationRailTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/NavigationRailTest.kt @@ -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), + ) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt index b006370..1be46d6 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreBrowseTest.kt @@ -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(), adjacentCategoryIds(categories, "not-a-genre")) + assertEquals(emptyList(), adjacentCategoryIds(categories, null)) + } + @Test fun `favourite overrides update a paged card copy immediately`() { val item = BaseItem(id = "film", name = "Film", type = "Movie") diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreRailScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreRailScreenshotTest.kt new file mode 100644 index 0000000..94c508f --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreRailScreenshotTest.kt @@ -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)) + } + } + } +} diff --git a/builder.env.example b/builder.env.example deleted file mode 100644 index b47b9c9..0000000 --- a/builder.env.example +++ /dev/null @@ -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= diff --git a/builder/.dockerignore b/builder/.dockerignore deleted file mode 100644 index aa49dba..0000000 --- a/builder/.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -** -!Dockerfile -!release.sh -!controller.go diff --git a/builder/Dockerfile b/builder/Dockerfile deleted file mode 100644 index d8bfb1d..0000000 --- a/builder/Dockerfile +++ /dev/null @@ -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"] diff --git a/builder/README.md b/builder/README.md deleted file mode 100644 index b526fa2..0000000 --- a/builder/README.md +++ /dev/null @@ -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-.apk -/data/releases/memby-.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. diff --git a/builder/controller.go b/builder/controller.go deleted file mode 100644 index be8ea01..0000000 --- a/builder/controller.go +++ /dev/null @@ -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}) -} diff --git a/builder/controller_test.go b/builder/controller_test.go deleted file mode 100644 index 99c2bc8..0000000 --- a/builder/controller_test.go +++ /dev/null @@ -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) - } -} diff --git a/builder/release.sh b/builder/release.sh deleted file mode 100644 index 18d10a6..0000000 --- a/builder/release.sh +++ /dev/null @@ -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 diff --git a/deploy-server.ps1 b/deploy-server.ps1 index 1e42c6e..4501c1b 100644 --- a/deploy-server.ps1 +++ b/deploy-server.ps1 @@ -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 + docker compose up -d --build --remove-orphans >/dev/null 2>&1 } 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 + docker compose down --remove-orphans >/dev/null 2>&1 ) || 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 - 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 +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' 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 + docker compose build --pull server memby-admin ) -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 + detail 'Stopping the previous Compose application' + ( + cd "$destination" + docker compose down --remove-orphans + ) 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' }) diff --git a/docker-compose.yml b/docker-compose.yml index 2a4f90b..171e5e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/memby-icon.png b/memby-icon.png new file mode 100644 index 0000000..a15afda Binary files /dev/null and b/memby-icon.png differ diff --git a/server/internal/api/admin.go b/server/internal/api/admin.go index fd0fbca..86a63f8 100644 --- a/server/internal/api/admin.go +++ b/server/internal/api/admin.go @@ -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)) diff --git a/server/internal/api/genres.go b/server/internal/api/genres.go index 88d05c7..8a0d14b 100644 --- a/server/internal/api/genres.go +++ b/server/internal/api/genres.go @@ -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 "": diff --git a/server/internal/api/items.go b/server/internal/api/items.go index 41c85d2..1a39246 100644 --- a/server/internal/api/items.go +++ b/server/internal/api/items.go @@ -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 diff --git a/server/internal/api/magic.go b/server/internal/api/magic.go index 6809112..27e0bf6 100644 --- a/server/internal/api/magic.go +++ b/server/internal/api/magic.go @@ -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 +} diff --git a/server/internal/api/release_builder.go b/server/internal/api/release_builder.go deleted file mode 100644 index 4732ba4..0000000 --- a/server/internal/api/release_builder.go +++ /dev/null @@ -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) -} diff --git a/server/internal/api/release_builder_test.go b/server/internal/api/release_builder_test.go deleted file mode 100644 index 28e3790..0000000 --- a/server/internal/api/release_builder_test.go +++ /dev/null @@ -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) - } -} diff --git a/server/internal/cache/cache.go b/server/internal/cache/cache.go index a95b120..ed30b39 100644 --- a/server/internal/cache/cache.go +++ b/server/internal/cache/cache.go @@ -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 } diff --git a/server/internal/config/config.go b/server/internal/config/config.go index d59a231..118e7f6 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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), diff --git a/server/internal/recommend/magic.go b/server/internal/recommend/magic.go index ac2b370..56bb0b7 100644 --- a/server/internal/recommend/magic.go +++ b/server/internal/recommend/magic.go @@ -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 + } + byID[candidate.ID] = candidate + score, signals := magicScore(profile, candidate, now) + pool = append(pool, MagicCandidate{ + ItemID: candidate.ID, + Title: candidate.Name, + Score: score, + RuntimeMinutes: candidate.RuntimeMinutes(), + Signals: signals, + }) } - selection.Reasons = Why(profile, selection.Item, 2) - return selection, true + 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) { diff --git a/server/internal/recommend/magic_test.go b/server/internal/recommend/magic_test.go new file mode 100644 index 0000000..1584afa --- /dev/null +++ b/server/internal/recommend/magic_test.go @@ -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 +}