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

@@ -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(