Web frontend
This commit is contained in:
6
python-backend/musicmouse/services/web/__init__.py
Normal file
6
python-backend/musicmouse/services/web/__init__.py
Normal 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"]
|
||||
192
python-backend/musicmouse/services/web/api.py
Normal file
192
python-backend/musicmouse/services/web/api.py
Normal 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
|
||||
104
python-backend/musicmouse/services/web/hub.py
Normal file
104
python-backend/musicmouse/services/web/hub.py
Normal 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()
|
||||
156
python-backend/musicmouse/services/web/schemas.py
Normal file
156
python-backend/musicmouse/services/web/schemas.py
Normal 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)
|
||||
88
python-backend/musicmouse/services/web/service.py
Normal file
88
python-backend/musicmouse/services/web/service.py
Normal 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()
|
||||
76
python-backend/musicmouse/services/web/settings.py
Normal file
76
python-backend/musicmouse/services/web/settings.py
Normal 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)
|
||||
47
python-backend/musicmouse/services/web/state.py
Normal file
47
python-backend/musicmouse/services/web/state.py
Normal 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,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user