Number keys: the keyboard's digits work like the remote's; a key can hold one song

The IR remote's digits and the kiosk keyboard's number keys now both emit
PlayRemoteSlotRequested, and one reaction resolves the slot for either (a podcast
show to its newest episode, an album to the album). POST /api/remote/press/{digit}
is the keyboard's way in. Mid-search a digit is still part of the query.

A remote slot can start one song instead of a whole album: `track_index` on an
album slot plays from that track, then the rest of the album. Shift+A on the play
screen arms "this song" where A arms "this album", and the assign popup offers
both. A series slot cannot name a song, and an index past the album's end is
rejected when saving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 19:41:34 +02:00
parent 92305bece0
commit 459599d2ba
22 changed files with 449 additions and 75 deletions

View File

@@ -217,6 +217,8 @@ copied to the device.
| `POST /api/play` | `{album_id, track_index?}` |
| `POST /api/resume` `/pause` `/next` `/previous` `/seek` | transport |
| `POST /api/volume` | `{percent}` or `{delta_percent}` |
| `POST /api/remote/press/{digit}` | what the IR remote's number key does - the keyboard's digits |
| `GET` `PUT /api/remote/mapping` | number key -> album (optionally from one `track_index`) or podcast show |
| `GET` `PUT /api/settings` | parent mode |
Two decisions worth knowing:

View File

@@ -178,8 +178,10 @@ figures:
# or any digit within it, for "unassigned" - a fresh install boots with none of this
# and that is not an error. Editable from the web front-end, which writes back here.
#
# target_kind: album -> always starts from the first track (music, audiobooks).
# target is an album id, as shown at GET /api/library.
# target_kind: album -> starts from the first track (music, audiobooks), or from
# track_index (0-based) when given - one song, followed by
# the rest of the album. target is an album id, as shown
# at GET /api/library.
# target_kind: series -> always plays the newest episode of a podcast show, resolved
# fresh on every press - never a fixed episode. target is the
# show's folder name under Kinderpodcasts, e.g. "Wissen macht Ah".
@@ -190,3 +192,7 @@ remote:
"2":
target_kind: series
target: "Wissen macht Ah"
"3":
target_kind: album
target: "3f9a0c12ab44"
track_index: 4

View File

@@ -347,8 +347,8 @@ class FigureConfig(_Strict):
class RemoteSlotConfig(_Strict):
"""What a number key on the IR remote plays.
``"album"``: ``target`` is an ``Album.id``, always started from track 0 - a music
album or an audiobook. ``"series"``: ``target`` is a podcast show name (an
``"album"``: ``target`` is an ``Album.id`` - a music album or an audiobook, started
from ``track_index``. ``"series"``: ``target`` is a podcast show name (an
``Album.series``); resolved to that show's newest episode fresh on every press,
since a podcast show is not itself one playable thing in this library - each
episode is its own album.
@@ -356,6 +356,15 @@ class RemoteSlotConfig(_Strict):
target_kind: Literal["album", "series"]
target: str
#: ``"album"`` only: start at this track instead of the first, then carry on with
#: the rest of the album - one song assigned to a key.
track_index: int = Field(default=0, ge=0)
@model_validator(mode="after")
def _check_track_index(self) -> Self:
if self.target_kind == "series" and self.track_index:
raise ValueError("track_index only applies to album slots")
return self
class Config(_Strict):

View File

@@ -15,12 +15,15 @@ from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Literal, TypeAlias
from typing import TYPE_CHECKING, Literal, TypeAlias
from musicmouse.effects import LedEffect
from musicmouse.hardware import Button, ButtonAction, LedZone, RotaryDirection, TouchButton
from musicmouse.media import Playlist, Track
if TYPE_CHECKING:
from musicmouse.config import Digit
__all__ = [
"ActiveFigureChanged",
"ButtonEvent",
@@ -37,6 +40,7 @@ __all__ = [
"PauseRequested",
"PlayAlbumRequested",
"PlayFigureRequested",
"PlayRemoteSlotRequested",
"PlayRequested",
"PlaySeriesLatestRequested",
"PlaybackChanged",
@@ -198,6 +202,17 @@ class PlaySeriesLatestRequested(IntentEvent):
series: str
@dataclass(frozen=True, slots=True, kw_only=True)
class PlayRemoteSlotRequested(IntentEvent):
"""Play whatever the top-level ``remote:`` mapping assigns to a number key.
The IR remote's digits emit this, and so do the number keys of the web front-end's
keyboard - one reaction resolves the slot for both.
"""
digit: Digit
@dataclass(frozen=True, slots=True, kw_only=True)
class SeekRequested(IntentEvent):
#: Seconds from the start of the current track.

View File

@@ -18,6 +18,7 @@ from musicmouse.events import (
PlayAlbumRequested,
PlayFigureRequested,
PlaylistFinished,
PlayRemoteSlotRequested,
PlayRequested,
PlaySeriesLatestRequested,
PreviousTrackRequested,
@@ -104,6 +105,27 @@ def play_series_latest(event: PlaySeriesLatestRequested, app: App) -> None:
_start_album(app, album, 0)
@on(PlayRemoteSlotRequested)
def play_remote_slot(event: PlayRemoteSlotRequested, app: App) -> None:
"""Play what a number key is assigned - pressed on the IR remote or a keyboard.
A slot naming one song starts the album there; the rest of the album follows, as
for any other start mid-album.
"""
slot = app.config.remote.get(event.digit)
if slot is None:
_log.debug("Number key %s is not assigned", event.digit)
return
if slot.target_kind == "series":
app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source=event.source))
return
album = app.library.get(slot.target)
if album is None:
_log.warning("Number key %s: no album %r in the library", event.digit, slot.target)
return
_start_album(app, album, slot.track_index)
@on(PlaylistFinished)
def playlist_finished(_event: PlaylistFinished, app: App) -> None:
# Nothing was left half-played, so the next placement starts from the top.

View File

@@ -21,9 +21,8 @@ from musicmouse.events import (
IntentEvent,
NextTrackRequested,
PauseRequested,
PlayAlbumRequested,
PlayRemoteSlotRequested,
PlayRequested,
PlaySeriesLatestRequested,
PreviousTrackRequested,
SetVolumeRequested,
ToggleScreenRequested,
@@ -156,12 +155,4 @@ class LircService:
self.app.bus.emit(SetVolumeRequested(volume=restore, source="lirc"))
def _play_digit(self, digit: Digit) -> None:
slot = self.app.config.remote.get(digit)
if slot is None:
return
if slot.target_kind == "album":
self.app.bus.emit(
PlayAlbumRequested(album_id=slot.target, track_index=0, source="lirc")
)
else:
self.app.bus.emit(PlaySeriesLatestRequested(series=slot.target, source="lirc"))
self.app.bus.emit(PlayRemoteSlotRequested(digit=digit, source="lirc"))

View File

@@ -26,6 +26,7 @@ from musicmouse.events import (
NextTrackRequested,
PauseRequested,
PlayAlbumRequested,
PlayRemoteSlotRequested,
PlayRequested,
PreviousTrackRequested,
SeekRequested,
@@ -290,6 +291,12 @@ def build_router(
raise HTTPException(status_code=404, detail="lirc not configured")
return LircConfigOut(connected=app.state.lirc_connected)
@router.post("/remote/press/{digit}", status_code=204)
def press_remote(digit: Digit) -> Response:
# A keyboard's number keys, doing what the IR remote's do. An unassigned key
# is not an error - the remote's own press of it is silent too.
return emit(PlayRemoteSlotRequested(digit=digit, source="web"))
@router.get("/remote/mapping")
def get_remote_mapping() -> RemoteMappingOut:
return read_mapping(app.config.remote, app.library)
@@ -308,7 +315,19 @@ def build_router(
status_code=422,
detail=f"key {digit}: no such {slot.target_kind} {slot.target!r}",
)
resolved[digit] = RemoteSlotConfig(target_kind=slot.target_kind, target=slot.target)
if slot.target_kind == "series" and slot.track_index:
raise HTTPException(
status_code=422,
detail=f"key {digit}: a podcast show has no fixed song to start at",
)
if slot.track_index >= len(found.tracks):
raise HTTPException(
status_code=422,
detail=f"key {digit}: {slot.target!r} has no track {slot.track_index}",
)
resolved[digit] = RemoteSlotConfig(
target_kind=slot.target_kind, target=slot.target, track_index=slot.track_index
)
app.config.remote = resolved
await asyncio.to_thread(write_mapping, config_path, resolved)

View File

@@ -32,6 +32,7 @@ def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -
target_kind=slot.target_kind,
target=slot.target,
resolved_album_id=_resolve(slot, library),
track_index=slot.track_index,
)
for digit, slot in sorted(remote.items())
]
@@ -40,9 +41,12 @@ def read_mapping(remote: dict[Digit, RemoteSlotConfig], library: MusicLibrary) -
def write_mapping(path: Path, mapping: dict[Digit, RemoteSlotConfig]) -> None:
document = load_document(path)
remote = {
digit: {"target_kind": slot.target_kind, "target": slot.target}
for digit, slot in mapping.items()
}
remote: dict[str, dict[str, str | int]] = {}
for digit, slot in mapping.items():
entry: dict[str, str | int] = {"target_kind": slot.target_kind, "target": slot.target}
# Only written when it says something, so a whole-album slot reads as before.
if slot.track_index:
entry["track_index"] = slot.track_index
remote[digit] = entry
document["remote"] = remote
atomic_write(path, document)

View File

@@ -281,6 +281,8 @@ class RemoteSlotOut(BaseModel):
#: resolves (a moved/deleted album, an unknown show), so the frontend can show a
#: broken-assignment state instead of silently dropping it.
resolved_album_id: str | None
#: "album" slots only: the song the key starts at; 0 for the whole album.
track_index: int = 0
class RemoteMappingOut(BaseModel):
@@ -290,6 +292,7 @@ class RemoteMappingOut(BaseModel):
class RemoteSlotIn(BaseModel):
target_kind: Literal["album", "series"]
target: str
track_index: int = Field(default=0, ge=0)
class RemoteMappingIn(BaseModel):

View File

@@ -96,6 +96,24 @@ def test_remote_mapping_rejects_an_unknown_target_kind(config_dir: Path) -> None
assert "remote" in message
def test_a_remote_slot_can_name_a_song(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["remote"] = {
"1": {"target_kind": "album", "target": "abc123", "track_index": 4},
"2": {"target_kind": "album", "target": "abc123"},
}
config = load_config(write_config(config_dir, data))
assert config.remote["1"].track_index == 4
assert config.remote["2"].track_index == 0
def test_a_series_slot_cannot_name_a_song(config_dir: Path) -> None:
data = copy.deepcopy(VALID_CONFIG)
data["remote"] = {"7": {"target_kind": "series", "target": "Wissen", "track_index": 1}}
message = _error(config_dir, data)
assert "track_index" in message
def test_ha_device_and_scene_name_is_optional(config_dir: Path) -> None:
data = _config(
ha={

View File

@@ -383,6 +383,29 @@ async def test_an_album_digit_plays_from_the_beginning(
assert sim.player.track_index == 0
async def test_a_song_digit_plays_from_that_song(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:
station, server = lircd
port = server.sockets[0].getsockname()[1]
album = next(a for a in sim.app.library.albums if a.title == "Kinderparty Lieder")
sim.app.config.remote = {
"3": RemoteSlotConfig(target_kind="album", target=album.id, track_index=1)
}
task = await _run_service(sim, _lirc_config(port))
try:
await station.wait_connected()
await station.send("0 00 BTN_3 Hauppauge")
await sim.bus.drain()
finally:
task.cancel()
assert sim.player.playlist is not None
assert sim.player.playlist.album_id == album.id
assert sim.player.track_index == 1
async def test_a_series_digit_plays_the_latest_episode(
lircd: tuple[ScriptedLircd, asyncio.base_events.Server], sim: Simulation
) -> None:

View File

@@ -8,7 +8,7 @@ from pathlib import Path
import httpx2
import pytest
from musicmouse.config import WebConfig, load_config
from musicmouse.config import RemoteSlotConfig, WebConfig, load_config
from musicmouse.services.web.service import build_app
from musicmouse.simulator.harness import Simulation, build_simulation
from tests.conftest import VALID_CONFIG, write_config
@@ -93,13 +93,93 @@ async def test_saving_an_album_slot_round_trips(
assert response.status_code == 200
slots = response.json()["slots"]
assert slots == [
{"digit": "3", "target_kind": "album", "target": target, "resolved_album_id": target}
{
"digit": "3",
"target_kind": "album",
"target": target,
"resolved_album_id": target,
"track_index": 0,
}
]
text = (config_dir / "config.yml").read_text(encoding="utf-8")
assert "# The MusicMouse config." in text # comments survive
reloaded = load_config(config_dir / "config.yml")
assert reloaded.remote["3"].target == target
assert "track_index" not in text # a whole-album slot reads as it always did
async def test_saving_a_song_slot_round_trips(
client: httpx2.AsyncClient, sim: Simulation, config_dir: Path
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
response = await client.put(
"/api/remote/mapping",
json={"slots": {"4": {"target_kind": "album", "target": target, "track_index": 1}}},
)
assert response.status_code == 200
assert response.json()["slots"][0]["track_index"] == 1
assert (await client.get("/api/remote/mapping")).json()["slots"][0]["track_index"] == 1
assert sim.app.config.remote["4"].track_index == 1
reloaded = load_config(config_dir / "config.yml")
assert reloaded.remote["4"].track_index == 1
async def test_a_song_past_the_end_of_the_album_is_rejected(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
response = await client.put(
"/api/remote/mapping",
json={"slots": {"4": {"target_kind": "album", "target": target, "track_index": 2}}},
)
assert response.status_code == 422
assert "no track 2" in response.json()["detail"]
async def test_a_song_of_a_series_is_rejected(client: httpx2.AsyncClient) -> None:
response = await client.put(
"/api/remote/mapping",
json={
"slots": {
"7": {"target_kind": "series", "target": "Wissen macht Ah", "track_index": 1}
}
},
)
assert response.status_code == 422
# --------------------------------------------------------------------------- pressing
async def test_pressing_a_key_plays_its_song_and_then_the_rest(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
target = await album_id(sim, "Kinderparty Lieder")
sim.app.config.remote = {
"4": RemoteSlotConfig(target_kind="album", target=target, track_index=1)
}
response = await client.post("/api/remote/press/4")
assert response.status_code == 204
await sim.bus.drain()
assert sim.player.playlist is not None
assert sim.player.playlist.album_id == target
assert len(sim.player.playlist) == 2 # the whole album, not just the one song
assert sim.player.track_index == 1
async def test_pressing_an_unassigned_key_does_nothing(
client: httpx2.AsyncClient, sim: Simulation
) -> None:
assert (await client.post("/api/remote/press/5")).status_code == 204
await sim.bus.drain()
assert sim.player.playlist is None
async def test_pressing_a_non_digit_is_rejected(client: httpx2.AsyncClient) -> None:
assert (await client.post("/api/remote/press/x")).status_code == 422
async def test_saving_a_series_slot_resolves_to_the_latest_episode(