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>
11 KiB
Task: eight independent cleanups
Each section below is a separate change: do them as separate commits, in any order, and
run scripts/check.sh after each. Each comes from a code review of commit a55cc4f;
the "why" says what is wrong and, where known, which commit introduced it. Do not
change behaviour beyond what a section asks for.
A. Races in library updates and concurrent rescans (review item 7)
Why. MusicLibrary.refresh() and MusicLibrary._publish_analysis()
(python-backend/musicmouse/library/__init__.py) both do
self._entries = await asyncio.to_thread(...) from a snapshot taken before the await.
Whichever finishes last wins, so an analysis batch that started before a rescan
finished can overwrite the freshly scanned index (a new podcast episode disappears
until the next rescan). Both also write index.json from worker threads at the same
time. Rescans are not serialised either: POST /api/library/refresh fires an
unretained asyncio.create_task (# noqa: RUF006 in services/web/api.py), and the
podcast poller rescans on its own (App.rescan_library, introduced with the poller in
57afc32).
Separately, VlcPlayer (devices/player.py) mutates _playing, _index and
_playlist_changed both from libVLC's callback thread (_on_playing, _on_stopped,
_on_playlist_end, _on_next_item) and from the event loop (_poll, the actions).
Do.
- One
asyncio.LockinMusicLibraryheld around every read-modify-write of_entriesplus itsstore_index: the whole ofrefresh(), and the fold-swap-store in_publish_analysis(). The analysis workers keep running while a scan holds the lock; only publishing waits. App.rescan_librarycoalesces. While one rescan runs, further calls wait for the running one plus at most one follow-up, not N parallel scans. Keep a reference to the task the refresh endpoint starts, and drop thenoqa.VlcPlayer's libVLC callbacks do nothing but hop onto the loop (the bus already knows the loop; givePlayerBasea small_on_loop(fn)usingloop.call_soon_threadsafe). All state mutation then happens on the loop thread. Update the comment above the callbacks.
Test. A library test with a scan made artificially slow (an injected scanner or a
patched scan_library) and an analysis publish racing it. The new album must survive.
Two concurrent rescan_library calls run the scanner at most twice.
B. Library cache invalidation gaps (review item 8)
Why. scan_library (library/scanner.py) reuses a cached folder album whenever
its audio files' names, sizes and mtimes are unchanged (Fingerprint). So:
- changing a figure's
kind:inconfig.ymlhas no effect until the figure's files change: the cached album keeps the old kind; - a
cover.jpg(or any of_COVER_NAMES) added to an album folder later is never picked up. Podcast episodes include their sidecar covers in the fingerprint; folder albums do not; - covers of albums that no longer exist (deleted folders, episodes pruned by the
podcast poller) stay in
cache/covers/forever.
Do.
- Folder albums: include the existing
_COVER_NAMESfiles in the fingerprint, likescan_episodesdoes for sidecars. - Figure albums: reuse a cached entry only if its
kindequals the configured one. - After a scan, delete
covers/<id>.jpgandcovers/<id>.thumb.jpgfor ids not in the new index. Do not garbage-collectanalysis/: it is expensive, content-keyed and documented as safe to keep. - Bump
_INDEX_VERSIONwith a comment line, because the fingerprint shape changes.
Test. One test per gap in tests/test_library.py.
C. The LED "off" animation is sent twice when a figure is removed (review item 11)
Why. Removing a figure makes reactions/lighting.figure_placed_or_removed run
off_animation. The player pausing then emits PlaybackChanged(playing=False), and
web_playback, whose guard is "no figure on the reader", runs off_animation again.
That makes 6 LED writes instead of 3 and restarts the reverse swipe halfway through.
web_playback came in with the web front-end (edb6e5e).
Do. Make off_animation idempotent: skip a zone whose current effect
(app.mouse.effect(zone)) is already the off effect it would send, and skip the button
brightness when it is already 0. That keeps both reactions simple and covers any future
third caller.
Test. In tests/test_reactions.py: removing a figure while playing produces
exactly three LedEffectChanged events, one per zone.
D. config.yml has no single owner (review item 13)
Why.
PUT /api/settings(services/web/api.py) applies values withsetattron the pydantic config objects. That skips validation, so the handler re-implements the min/max/initial volume checks by hand.PUT /api/remote/mappingreplacesapp.config.remote.- Both endpoints read, modify and write
config.ymlfrom worker threads (write_settings,write_mapping) with no lock, so two saves at once can lose one. - Memory is updated before the file is written, so a failed write leaves the running config and the file disagreeing.
Do. One small ConfigStore (for example musicmouse/config_store.py) owns the path,
the in-memory Config and a lock. An update:
- validates the merged values through the real pydantic models, so the
_check_volumesvalidator runs; no duplicated checks in the endpoint; - writes the file (round-trip, comments kept, via the existing
load_document/atomic_write); - only then applies the values to the live objects.
Objects that read the config live (ScreenService reads auto_off_minutes, the player's
volume limits) must keep seeing changes: either keep mutating the same objects after
validation, or have them read through the store. Pick one and say which in the
docstring. The settings and remote-mapping endpoints both go through the store. If
docs/agent-tasks/01-web-authentication.md has landed, its --set-password command
should use it too.
Test. Invalid settings are rejected by the model's own validator, with an unchanged file and unchanged memory. Two concurrent saves (settings and mapping) both end up in the file.
E. Generate the front-end's API types (review item 15)
Why. web/src/api/types.ts (~300 lines) hand-mirrors
python-backend/musicmouse/services/web/schemas.py. FastAPI already describes every
route and model at /api/openapi.json. (A third copy lived in the Slint front-end,
which has been removed.)
Do.
- A backend test (for example
tests/test_openapi.py) builds the app the waytests/test_web.pydoes and comparesapi.openapi()with a committedweb/src/api/openapi.json. WithUPDATE_OPENAPI=1it rewrites the file instead. The test fails whenever the API changes and the snapshot was not refreshed. web: addopenapi-typescriptas a dev dependency and annpm run gen:apiscript that generatessrc/api/schema.d.tsfrom that JSON. Makenpm run checkfail when the generated file is stale: use the tool's own check mode if it has one (verify), otherwise regenerate to a temp file and diff.types.tskeeps its exported names (Album,PlayerState, ...) as aliases ofcomponents["schemas"][...], so no component changes. Move the useful doc comments onto the pydantic fields (Field(description=...)), where they reach both sides.- The websocket messages (
ServerMessage) are not in OpenAPI. Keep them hand-written intypes.tswith a comment saying so.
F. Copy-pasted helpers (review item 16)
Why.
ScreenService.blubb(services/screen.py) is a line-for-line copy ofsound.play_wav.sound.pywas extracted ina55cc4f, but the screen service was never moved onto it.- The "did a person do this?" filter (an
IntentEvent, notLedEffectRequested, notsource == "system") is duplicated inScreenService._on_eventandTimerService._on_event. - There are five atomic-write helpers:
library/cache._write_atomic,services/web/settings.atomic_write,tippen/progress.save_progress,school_days.save_calendarandlibrary/podcast_feeds._save_failed. Three of them name the temp file<name>.tmp<pid>, which collides when two threads of the same process write the same file. That really happens: settings and remote-mapping saves both run inasyncio.to_thread.
Do.
ScreenService.blubbcallsplay_wav(self.alsa_device, wav, timeout=self.config.timeout).is_person(event: Event) -> boolnext to the event classes inevents.py, used by both services (keep each service's own extra exclusions).- One
write_atomic(path, text)(for examplemusicmouse/fileio.py) usingtempfile.NamedTemporaryFile(dir=path.parent, delete=False)andos.replace, used by all five. Keep the temp file a dotfile where it sits in the music library, so the scanner never picks it up. The YAML writer dumps to a string first.
Test. The existing tests cover behaviour. Add one for write_atomic, two threads
writing the same path at once, which today can fail.
G. Inject time everywhere (review item 17)
Why. The backend's rule (see musicmouse/clock.py) is that anything that waits takes a
Clock, and TimerService takes a wall callable for the date. Exceptions:
PodcastFeedService.runandStateHub.runcallasyncio.sleepdirectly;services/web/state.clock_stateusesdate.today();POST /api/timer(services/web/api.py) usesdatetime.now().
Do. Give PodcastFeedService and StateHub a clock, as the other services have,
wired in __main__._build_services / WebService. Put one wall: Callable[[], datetime] on App (default datetime.now) and use it in TimerService,
clock_state and the timer endpoint, so a test can pin "today" in one place.
Test. The podcast service test runs on FakeClock instead of real sleeps; a
clock-state test pins the date through App.wall.
H. Break up the largest files (review item 19)
Why. Every feature since the rearchitecture was added to the same few files.
web/src/App.tsxis 909 lines with ~40 hooks, routes pages via flags, and definesToolButton,CornerButton,ConnectionDot,Splashand the assign toast inline.BrowseView.tsxis 932 lines.- About 300 static
style={{...}}objects sit next to five CSS files. services/web/api.pyis one router for seven features: library, state, playback, parent mode, typing game, remote, Home Assistant.- One sign of insertion rather than placement: the comment
/** The one place a keyboard action, a click or a tap all end up. */inApp.tsxsits abovesaveMapping, but describesrun.
Do (mechanical moves only, no behaviour change; one commit per bullet):
api.py: one router module per feature underservices/web/routes/, assembled inbuild_router. URLs unchanged.App.tsx: move the four small components intocomponents/, the remote-mapping state intohooks/useRemoteMapping.ts, and the kiosk listeners (screen activity, silencing a ringing timer) intohooks/useKioskActivity.ts. Put the misplaced comment back aboverun.BrowseView.tsx: split by screen (root shelves, category, results), whichever seams the file already has.- Inline styles: move static style objects in
App.tsx,BrowseView.tsxandParentPanel.tsxinto classes in the existing CSS files. Leave dynamic ones (colours from album data, computed sizes) inline.
Check visually in the dev server, at desktop, phone (usePhoneLayout) and ?pi=1
sizes, that nothing moved.