Add agent task files for the review's remaining fixes

Authentication (01), one input policy for skip/mute/volume steps (04), the
firmware's interleaved serial frames (05), podcast as an album kind (06), and eight
smaller cleanups (07). Each says why, what was decided, the design, the tests and
when it is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 10:34:28 +02:00
parent a737ccf108
commit 4cb2d085ca
6 changed files with 648 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
# Task: one behaviour per button, whatever it is pressed on
## Why
The backend's README promises that front-ends only translate input into intents and that
`musicmouse/reactions/` is the one place that decides what happens. Three behaviours
break that promise today, so the same request does different things depending on where
it came from:
| Behaviour | Web front-end | IR remote | Mouse buttons / knob | MQTT |
|---|---|---|---|---|
| Next/previous on a podcast | seeks ±30 s (`web/src/App.tsx`, `skipPodcast`) | skips a track | skips a track | skips a track |
| Unmute | jumps to 60 % (`UNMUTE_PERCENT` in `App.tsx`) | restores the previous volume (`LircService._toggle_mute`) | - | - |
| Volume step | J/K: 10 % (`VOLUME_STEP` in `web/src/lib/keyboard.ts`); +/- buttons: `volume_increment` | `volume_increment` | `volume_increment` × detents | absolute only |
There is also a confirmed bug. With `min_volume > 0`, the remote's mute never unmutes:
"mute" asks for volume 0, the player clamps it to `min_volume`, the next press sees
`volume > 0` and mutes again.
## What the owner decided
1. **Next/previous on a podcast episode always seeks 30 s** forward/back, from every
input: web (buttons and keys), IR remote, the mouse's buttons, MQTT.
2. **Unmute always restores the volume from before muting**, from every input.
3. **Parent mode gets three separate volume-step settings**: the web keyboard's J/K,
the mouse's rotary knob, and the IR remote. Each is configured independently.
## Design
### 1. Podcast skip lives in the backend
- In `musicmouse/reactions/playback.py`, `next_track` / `previous_track` look at the
current album (`app.album_for(app.player.playlist)`). For a podcast episode they seek
instead: `position ± PODCAST_SKIP_SECONDS` (30, a module constant), clamped to
`[0, duration]`. Take the duration from the library track when the player does not
know it yet, the way `services/web/state.py` does.
- "Is this a podcast?" is decided by **one** helper. If `docs/agent-tasks/06-podcast-kind.md`
has landed, that is `album.kind == "podcast"`. If not, add `is_podcast(album)` next to
`Album` (`musicmouse/library/models.py`) that checks the section's
`album_unit == "episode"`, and let task 06 change its body.
- `web/src/App.tsx`: the `next` / `previous` actions always call `api.next()` /
`api.previous()`. Delete `skipPodcast` and `PODCAST_SKIP_SECONDS`. Drop the optimistic
`position: 0` patch too: the client no longer knows what the backend will do. If the
≤0.5 s until the next position frame is visibly laggy, let `StateHub` push a
`position` frame right after a next/previous/seek intent has been handled. Don't
re-add client-side policy.
- The mouse's left/right buttons only act while playing (`button_pressed`); keep that.
### 2. Mute is a player state, not a volume
- Add `muted: bool` to the `Player` protocol, plus `set_muted(muted, *, source)`,
implemented in `PlayerBase`. `VlcPlayer` applies it with libVLC's
`audio_set_mute`; `FakePlayer` just records it. Muting does **not** touch `volume`,
so unmuting restores it by construction, and `min_volume` cannot interfere.
- Announce changes with a new `StateEvent` (for example `MuteChanged`) and add it to
`SNAPSHOT_EVENTS` in `services/web/hub.py`. A test fails until every `StateEvent` is
sorted into that list or `IGNORED_EVENTS`.
- A new intent, `ToggleMuteRequested`, handled by a reaction. Any volume change
(`SetVolumeRequested`, `VolumeChangeRequested`, the step intent below) also unmutes;
that is what every TV remote does.
- `POST /api/mute/toggle`. The web's Shift+M and the mute button call it. Delete
`UNMUTE_PERCENT` and `onMute`'s volume arithmetic.
- `LircService`: `KEY_MUTE` emits `ToggleMuteRequested`. Delete `_pre_mute_volume` and
`_toggle_mute`.
- The state snapshot (`PlayerStateOut`, `web/src/api/types.ts`) gets `muted`. The volume
bars show muted distinctly instead of showing volume 0.
- MQTT is out of scope. Its volume number stays absolute, and setting it unmutes via
the rule above.
### 3. Three volume steps
- Config: replace `general.volume_increment` with
`general.volume_steps: {keyboard: int, knob: int, remote: int}`, all in **device
units** like `min_volume` / `max_volume`, each `ge=1, le=100`. Defaults: `keyboard: 10`,
`knob: 5`, `remote: 5` (today's behaviour for knob and remote).
- `volume_increment` in an existing `config.yml` must fail with a message naming the
replacement ("replaced by volume_steps.knob / .remote / .keyboard"). Config
validation rejects unknown keys anyway; make the message specific rather than the
generic "unknown option".
- One intent: `VolumeStepRequested(direction: Literal[-1, 1], count: int = 1,
input: Literal["keyboard", "knob", "remote"])`. One reaction multiplies by the
configured step and calls `change_volume`.
- `rotary_turned` emits it with `input="knob"`, `count=abs(event.increment)`.
- `LircService` volume keys emit it with `input="remote"`.
- `POST /api/volume` takes `{"steps": n, "input": "keyboard" | "remote"}` instead of
bare `steps`. J/K send `input: "keyboard"`. The on-screen −/+ buttons keep taking
the remote's step, as 1254790 deliberately chose, so they send `input: "remote"`.
- `{"percent": n}` stays for the volume bars.
- Parent mode: `SettingsIn` / `SettingsOut` (`services/web/schemas.py`),
`read_settings` / `write_settings` (`services/web/settings.py`, writing the nested
mapping), and `web/src/components/ParentPanel.tsx` get three fields, labelled in
German (for example "Lautstärke-Schritt: Tastatur (J/K)", "… Drehknopf",
"… Fernbedienung"). They replace the single "rotary step". Saving applies live, like
the other settings.
- Update `python-backend/config.yml.example`, the route table in
`python-backend/README.md`, `web/README.md`'s key table, and `HelpOverlay.tsx`.
## Tests
- Reactions (`tests/test_reactions.py`, through the simulator):
- Next/previous on a podcast episode seeks ±30 s and clamps at both ends. Use a
`Kinderpodcasts` folder in the test library (see `tests/conftest.py`).
- Next/previous on music still changes track.
- Mute then unmute restores the exact volume, including with `min_volume > 0` (the
regression).
- A volume change while muted unmutes.
- Each of the three inputs moves by its own configured step.
- `tests/test_lirc.py`: the mute key and the volume keys emit the new intents.
- `tests/test_web.py`: `/api/mute/toggle`; `/api/volume` with `input`; the settings
round-trip for `volume_steps`, which keeps the file's comments.
- `tests/test_config.py`: the defaults, and the specific error for `volume_increment`.
- `web/src/lib/__tests__/keyboard.test.ts`: J/K produce the step action, not a
percentage.
## Done when
`scripts/check.sh` passes, `grep -rn "PODCAST_SKIP\|UNMUTE_PERCENT\|volume_increment"`
finds nothing in `web/src` and `python-backend/musicmouse` apart from the config error
that names the old key, and in `--simulate` a
podcast skips 30 s from the web, from `press right` in the REPL, and from a simulated
remote key.