Web frontend

This commit is contained in:
2026-08-27 12:32:20 +02:00
parent d44c24ec97
commit edb6e5e027
97 changed files with 9535 additions and 195 deletions

View File

@@ -0,0 +1,6 @@
"""The web front-end: a browse-and-play UI over the same intents the buttons emit."""
from musicmouse.services.web.hub import StateHub
from musicmouse.services.web.service import WebService, build_app
__all__ = ["StateHub", "WebService", "build_app"]

View File

@@ -0,0 +1,192 @@
"""The HTTP surface.
Commands are REST and state is a one-way websocket. That split keeps every control path
testable with ``curl`` and means a command needs no new machinery: it emits the same
intent the MQTT service and the buttons emit, and lands in the same reaction.
Search is not here on purpose. The whole index goes to the browser once and filtering
happens there, which is what makes the design's type-to-search feel instant.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from musicmouse.app import App
from musicmouse.events import (
IntentEvent,
NextTrackRequested,
PauseRequested,
PlayAlbumRequested,
PlayRequested,
PrevTrackRequested,
SeekRequested,
SetVolumeRequested,
)
from musicmouse.services.web.hub import StateHub
from musicmouse.services.web.schemas import (
AlbumOut,
BeatsOut,
LibraryOut,
PlayerStateOut,
PlayIn,
SeekIn,
SettingsIn,
SettingsOut,
VolumeIn,
)
from musicmouse.services.web.settings import (
read_settings,
to_device_volume,
to_percent,
write_settings,
)
from musicmouse.services.web.state import snapshot
_log = logging.getLogger(__name__)
__all__ = ["build_router"]
def build_router(app: App, hub: StateHub, config_path: Path) -> APIRouter:
router = APIRouter(prefix="/api")
def emit(intent: IntentEvent) -> Response:
app.bus.emit(intent)
return Response(status_code=204)
# ------------------------------------------------------------------- library
@router.get("/library")
def get_library() -> LibraryOut:
return LibraryOut(albums=[AlbumOut.of(album) for album in app.library.albums])
@router.get("/albums/{album_id}/cover")
def get_cover(album_id: str) -> FileResponse:
album = app.library.get(album_id)
if album is None or album.cover is None:
# Not an error: the client paints the album's own colours instead.
raise HTTPException(status_code=404, detail="no cover")
return FileResponse(
album.cover,
# Cover files are content-addressed by album id and rewritten only by a
# rescan, so a long cache is safe and saves 20 requests per page load.
headers={"Cache-Control": "public, max-age=604800"},
)
@router.get("/tracks/{album_id}/{index}/analysis")
def get_track_analysis(album_id: str, index: int) -> BeatsOut:
grid = app.library.beats(album_id, index)
if grid is None:
raise HTTPException(status_code=404, detail="not analyzed")
return BeatsOut(times=list(grid.times), strengths=list(grid.strengths))
@router.post("/library/refresh", status_code=202)
async def refresh_library() -> Response:
async def rescan() -> None:
await app.library.refresh()
app.playlists.clear()
app.playlists.update(app.library.figure_playlists())
await hub.broadcast_library()
# Returns immediately: a cold rescan reads tags from every file.
asyncio.create_task(rescan()) # noqa: RUF006
return Response(status_code=202)
# --------------------------------------------------------------------- state
@router.get("/state")
def get_state() -> PlayerStateOut:
return snapshot(app)
@router.websocket("/ws")
async def websocket(socket: WebSocket) -> None:
await hub.connect(socket)
try:
while True:
# Push-only. Reading is how we notice the tab closed.
await socket.receive_text()
except WebSocketDisconnect:
pass
finally:
hub.disconnect(socket)
# ------------------------------------------------------------------ commands
@router.post("/play", status_code=204)
def play_album(body: PlayIn) -> Response:
if app.library.get(body.album_id) is None:
raise HTTPException(status_code=404, detail="no such album")
return emit(
PlayAlbumRequested(
album_id=body.album_id, track_index=body.track_index, source="web"
)
)
@router.post("/resume", status_code=204)
def resume() -> Response:
return emit(PlayRequested(source="web"))
@router.post("/pause", status_code=204)
def pause() -> Response:
return emit(PauseRequested(source="web"))
@router.post("/next", status_code=204)
def next_track() -> Response:
return emit(NextTrackRequested(source="web"))
@router.post("/previous", status_code=204)
def previous_track() -> Response:
return emit(PrevTrackRequested(source="web"))
@router.post("/seek", status_code=204)
def seek(body: SeekIn) -> Response:
return emit(SeekRequested(position=body.position, source="web"))
@router.post("/volume", status_code=204)
def set_volume(body: VolumeIn) -> Response:
general = app.config.general
if body.percent is not None:
target = body.percent
elif body.delta_percent is not None:
target = to_percent(app.player.volume, general) + body.delta_percent
else:
raise HTTPException(status_code=422, detail="percent or delta_percent required")
return emit(
SetVolumeRequested(
volume=to_device_volume(max(0, min(100, target)), general), source="web"
)
)
# ------------------------------------------------------------- parent mode
@router.get("/settings")
def get_settings() -> SettingsOut:
return read_settings(app.config.general)
@router.put("/settings")
async def put_settings(body: SettingsIn) -> SettingsOut:
if body.min_volume > body.max_volume:
raise HTTPException(
status_code=422, detail="min_volume must not exceed max_volume"
)
if not body.min_volume <= body.initial_volume <= body.max_volume:
raise HTTPException(
status_code=422, detail="initial_volume must lie between min and max"
)
general = app.config.general
# Applied live as well as saved: a parent lowering the ceiling expects the next
# song to be quieter, not the next boot.
for key, value in body.model_dump().items():
setattr(general, key, value)
app.player.set_volume_limits(general.min_volume, general.max_volume)
await asyncio.to_thread(write_settings, config_path, body)
await hub.broadcast_state()
return read_settings(general)
return router

View File

@@ -0,0 +1,104 @@
"""Getting state out to every open browser tab.
State events only fire on *change*, so a tab that connects halfway through a track
would otherwise sit there knowing nothing. The fix is the one
:class:`~musicmouse.services.mqtt.service.MqttService` already uses for a reconnecting
broker: send a full snapshot on connect, then deltas.
Position is the exception to "everything goes on the bus". A progress bar wants it
twice a second; an event at that rate would flood the queue, the MQTT service and the
log to serve one front-end. So the hub reads it straight off the player on its own
timer, and only while something is playing.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from typing import Any
from fastapi import WebSocket
from musicmouse.app import App
from musicmouse.events import Event, StateEvent
from musicmouse.services.web.state import snapshot
_log = logging.getLogger(__name__)
__all__ = ["StateHub"]
#: Twice a second: smooth enough once the client interpolates between frames, cheap
#: enough to leave running.
POSITION_INTERVAL = 0.5
class StateHub:
def __init__(self, app: App) -> None:
self.app = app
self._clients: set[WebSocket] = set()
self._unsubscribe: Any = None
# ------------------------------------------------------------------- lifecycle
def start(self) -> None:
self._unsubscribe = self.app.bus.subscribe(StateEvent, self._on_state)
def stop(self) -> None:
if self._unsubscribe is not None:
self._unsubscribe()
self._unsubscribe = None
async def run(self) -> None:
"""Push the playback position while anything is playing."""
while True:
await asyncio.sleep(POSITION_INTERVAL)
if not self._clients or not self.app.player.is_playing:
continue
await self.broadcast(
{
"type": "position",
"position": self.app.player.position,
"duration": self.app.player.duration,
}
)
# --------------------------------------------------------------------- clients
async def connect(self, socket: WebSocket) -> None:
await socket.accept()
self._clients.add(socket)
await self._send(socket, {"type": "state", "state": snapshot(self.app).model_dump()})
def disconnect(self, socket: WebSocket) -> None:
self._clients.discard(socket)
# ------------------------------------------------------------------ publishing
async def broadcast(self, message: dict[str, Any]) -> None:
if not self._clients:
return
payload = json.dumps(message)
for socket in list(self._clients):
try:
await socket.send_text(payload)
except Exception:
# A tab that closed mid-send is normal, not an error worth logging loudly.
_log.debug("Dropping a websocket client that went away")
self._clients.discard(socket)
async def broadcast_state(self) -> None:
await self.broadcast({"type": "state", "state": snapshot(self.app).model_dump()})
async def broadcast_library(self) -> None:
await self.broadcast({"type": "library"})
# ------------------------------------------------------------------ internals
async def _send(self, socket: WebSocket, message: dict[str, Any]) -> None:
with contextlib.suppress(Exception):
await socket.send_text(json.dumps(message))
async def _on_state(self, _event: Event) -> None:
await self.broadcast_state()

View File

@@ -0,0 +1,156 @@
"""What the browser sees.
Two rules shape these. The whole library ships in one response, because the design's
incremental A-Z search has to be instant and ~25 albums of metadata is under 100 kB -
so no track carries anything it does not need. And volume is a *percentage* here: the
configured ceiling is a parent's business, not a child's, so it never crosses this line.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from musicmouse.library import Album
from musicmouse.library.analysis import TrackAnalysis
__all__ = [
"AlbumOut",
"BeatsOut",
"LibraryOut",
"PlayIn",
"PlayerStateOut",
"SeekIn",
"SettingsIn",
"SettingsOut",
"TrackOut",
"VolumeIn",
]
class AnalysisOut(BaseModel):
tempo: float | None = None
energy: float | None = None
valence: float | None = None
brightness: float | None = None
beats: bool = False
@classmethod
def of(cls, analysis: TrackAnalysis | None) -> AnalysisOut | None:
if analysis is None or not analysis.is_analyzed:
return None
return cls(
tempo=analysis.tempo,
energy=analysis.energy,
valence=analysis.valence,
brightness=analysis.brightness,
beats=analysis.beats,
)
class TrackOut(BaseModel):
title: str
duration: float
#: Scalars only. The beat grid is fetched per track from ``/api/tracks/...``.
analysis: AnalysisOut | None = None
class AlbumOut(BaseModel):
id: str
section: str
kind: str
title: str
artist: str
series: str | None
figure: str | None
category: str
colors: list[str]
has_cover: bool
duration: float
tracks: list[TrackOut]
@classmethod
def of(cls, album: Album) -> AlbumOut:
return cls(
id=album.id,
section=album.section,
kind=album.kind,
title=album.title,
artist=album.artist,
series=album.series,
figure=album.figure,
category=album.category,
colors=list(album.colors),
has_cover=album.cover is not None,
duration=album.duration,
tracks=[
TrackOut(
title=track.title,
duration=track.duration,
analysis=AnalysisOut.of(track.analysis),
)
for track in album.tracks
],
)
class LibraryOut(BaseModel):
albums: list[AlbumOut]
class BeatsOut(BaseModel):
times: list[float]
strengths: list[float]
class ConnectionOut(BaseModel):
firmware: bool
mqtt: bool
class PlayerStateOut(BaseModel):
playing: bool
album_id: str | None
album_title: str | None
artist: str | None
kind: str | None
track_index: int
track_title: str | None
track_count: int
position: float
duration: float
#: 0..100. The device's own range is deliberately not exposed.
volume: int
active_figure: str | None
connected: ConnectionOut
class PlayIn(BaseModel):
album_id: str
track_index: int = Field(default=0, ge=0)
class SeekIn(BaseModel):
position: float = Field(ge=0)
class VolumeIn(BaseModel):
"""Either an absolute percentage or a relative step; exactly one of the two."""
percent: int | None = Field(default=None, ge=0, le=100)
delta_percent: int | None = Field(default=None, ge=-100, le=100)
class SettingsOut(BaseModel):
min_volume: int
max_volume: int
initial_volume: int
volume_increment: int
button_leds_brightness: float
class SettingsIn(BaseModel):
min_volume: int = Field(ge=0, le=200)
max_volume: int = Field(ge=0, le=200)
initial_volume: int = Field(ge=0, le=200)
volume_increment: int = Field(ge=1, le=100)
button_leds_brightness: float = Field(ge=0, le=1)

View File

@@ -0,0 +1,88 @@
"""The web front-end, as a :class:`~musicmouse.services.base.Service`.
Everything runs on the one event loop the rest of the app already has: uvicorn's
``Server.serve()`` is a coroutine, so there is no second loop and no thread. The
service owns no state of its own - it turns HTTP into intents on the bus, and bus
events into websocket frames.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from pathlib import Path
import uvicorn
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from musicmouse.app import App
from musicmouse.config import WebConfig
from musicmouse.services.web.api import build_router
from musicmouse.services.web.hub import StateHub
_log = logging.getLogger(__name__)
__all__ = ["WebService", "build_app"]
def build_app(app: App, config: WebConfig, config_path: Path) -> tuple[FastAPI, StateHub]:
"""Assemble the ASGI app. Separate from the service so tests can drive it directly."""
hub = StateHub(app)
api = FastAPI(title="MusicMouse", docs_url="/api/docs", openapi_url="/api/openapi.json")
api.include_router(build_router(app, hub, config_path))
if config.static_dir is not None:
if config.static_dir.is_dir():
# Mounted last and at the root so every /api route still wins; html=True
# falls back to index.html, which is what a client-side router needs.
api.mount("/", StaticFiles(directory=config.static_dir, html=True), name="web")
else:
_log.warning(
"web.static_dir %s does not exist; serving the API only "
"(run `npm run build` in web/, or drop the setting)",
config.static_dir,
)
return api, hub
class WebService:
name = "web"
def __init__(self, app: App, config: WebConfig, config_path: Path) -> None:
self.config = config
self.api, self.hub = build_app(app, config, config_path)
async def run(self) -> None:
self.hub.start()
server = uvicorn.Server(
uvicorn.Config(
self.api,
host=self.config.host,
port=self.config.port,
# Access logs for a progress-bar poll every 500ms are noise.
access_log=False,
log_level="warning",
)
)
_log.info("Web front-end on http://%s:%d", self.config.host, self.config.port)
# serve() borrows SIGINT for a graceful shutdown and then re-raises it, so
# Ctrl-C still reaches __main__'s KeyboardInterrupt handler afterwards.
position = asyncio.create_task(self.hub.run(), name="web-position")
serving = asyncio.create_task(server.serve(), name="web-serve")
try:
await asyncio.shield(serving)
except asyncio.CancelledError:
# Shutdown reaches us as a cancellation. Cancelling uvicorn mid-accept
# would leave its listening socket to the garbage collector, so ask it to
# wind down and wait for it to let the port go.
server.should_exit = True
with contextlib.suppress(asyncio.CancelledError, Exception):
await asyncio.wait_for(serving, timeout=5.0)
raise
finally:
position.cancel()
serving.cancel()
self.hub.stop()

View File

@@ -0,0 +1,76 @@
"""Volume as a percentage, and the handful of settings parent mode may change.
Two jobs that both come down to "keep the config file's numbers out of the child's UI".
The volume mapping lives here rather than in the frontend so that MQTT, the rotary
encoder and the firmware carry on speaking device units, unaware that a browser is
using a different scale.
"""
from __future__ import annotations
import logging
import os
from pathlib import Path
from ruamel.yaml import YAML
from musicmouse.config import GeneralConfig
from musicmouse.services.web.schemas import SettingsIn, SettingsOut
_log = logging.getLogger(__name__)
__all__ = ["read_settings", "to_device_volume", "to_percent", "write_settings"]
def to_device_volume(percent: int, general: GeneralConfig) -> int:
"""Map 0..100 onto the configured range, so 100 % is exactly ``max_volume``."""
span = general.max_volume - general.min_volume
return general.min_volume + round(span * max(0, min(100, percent)) / 100)
def to_percent(volume: int, general: GeneralConfig) -> int:
"""The inverse, for the state snapshot."""
span = general.max_volume - general.min_volume
if span <= 0:
return 100
return max(0, min(100, round((volume - general.min_volume) / span * 100)))
def read_settings(general: GeneralConfig) -> SettingsOut:
return SettingsOut(
min_volume=general.min_volume,
max_volume=general.max_volume,
initial_volume=general.initial_volume,
volume_increment=general.volume_increment,
button_leds_brightness=general.button_leds_brightness,
)
def write_settings(path: Path, settings: SettingsIn) -> None:
"""Patch the settings into ``config.yml`` in place.
Loaded round-trip rather than with the safe loader :mod:`musicmouse.config` uses,
so the file keeps its comments, ordering and formatting - a config that explains
itself is worth more than one this could rewrite from scratch. Written through a
sibling temp file so an interrupted save cannot truncate the real one.
"""
yaml = YAML(typ="rt")
yaml.preserve_quotes = True
with path.open(encoding="utf-8") as handle:
document = yaml.load(handle)
general = document["general"]
for key, value in settings.model_dump().items():
general[key] = value
temp = path.with_name(f"{path.name}.tmp{os.getpid()}")
try:
with temp.open("w", encoding="utf-8") as handle:
yaml.dump(document, handle)
temp.replace(path)
except BaseException:
temp.unlink(missing_ok=True)
raise
_log.info("Wrote settings to %s", path)

View File

@@ -0,0 +1,47 @@
"""One place that answers "what is the mouse doing right now?".
Assembled on demand rather than accumulated from events. The join between what is
playing and which figure is on the reader is the same one the MQTT player entity makes:
read ``mouse.active_figure``, do not keep a second copy of it that can drift.
"""
from __future__ import annotations
from musicmouse.app import App
from musicmouse.services.web.schemas import ConnectionOut, PlayerStateOut
from musicmouse.services.web.settings import to_percent
__all__ = ["snapshot"]
def snapshot(app: App) -> PlayerStateOut:
player = app.player
playlist = player.playlist
album = app.library.get(playlist.album_id if playlist else None)
index = player.track_index
# The player only knows a file path, and a tagged file's name is rarely its title
# ("01 - So ein schoener Tag.mp3"). The library read the real one at scan time.
indexed = (
album.tracks[index] if album and 0 <= index < len(album.tracks) else None
)
track = player.current_track
return PlayerStateOut(
playing=player.is_playing,
album_id=album.id if album else None,
album_title=album.title if album else (playlist.name if playlist else None),
artist=album.artist if album else None,
kind=album.kind if album else None,
track_index=index,
track_title=indexed.title if indexed else (track.title if track else None),
track_count=len(playlist) if playlist else 0,
position=player.position,
# Likewise the duration: the player only has one once libVLC has opened the file.
duration=(indexed.duration if indexed else 0.0) or player.duration,
volume=to_percent(player.volume, app.config.general),
active_figure=app.mouse.active_figure,
connected=ConnectionOut(
firmware=app.mouse.connected,
mqtt=app.state.mqtt_connected,
),
)