Web frontend
This commit is contained in:
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Self
|
||||
from typing import Annotated, Any, Final, Literal, Self
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
@@ -26,24 +26,31 @@ from ruamel.yaml.error import YAMLError
|
||||
|
||||
from musicmouse.color import ColorRGBW, parse_color
|
||||
from musicmouse.hardware import NO_FIGURE_TAG, RFID_TAG_LENGTH
|
||||
from musicmouse.media import Playlist, build_playlist
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"SIMULATE",
|
||||
"Config",
|
||||
"ConfigError",
|
||||
"FigureColors",
|
||||
"FigureConfig",
|
||||
"GeneralConfig",
|
||||
"LibraryConfig",
|
||||
"MqttConfig",
|
||||
"build_playlists",
|
||||
"WebConfig",
|
||||
"format_validation_error",
|
||||
"load_config",
|
||||
]
|
||||
|
||||
DEFAULT_AUDIO_EXTENSIONS = (".mp3", ".ogg", ".oga", ".opus", ".flac", ".wav", ".m4a", ".aac")
|
||||
|
||||
#: Stand-in value for ``serial_port`` and ``alsa_device``. Running without the mouse or
|
||||
#: without sound is a supported setup, but it has to be *asked for*: a missing key is an
|
||||
#: error, so a config that lost a line fails loudly instead of booting into a silent
|
||||
#: mouse that looks like it is working.
|
||||
SIMULATE: Final = "simulate"
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Raised with an already human-readable, multi-line message."""
|
||||
@@ -101,6 +108,17 @@ class FigureColors(_Strict):
|
||||
return data
|
||||
|
||||
|
||||
def _resolve_folder(folder: Path, info: ValidationInfo, *, must_exist: bool) -> Path:
|
||||
"""Make a configured path absolute against the config file, and optionally check it."""
|
||||
context = info.context or {}
|
||||
base = context.get("config_dir")
|
||||
if base is not None and not folder.is_absolute():
|
||||
folder = (Path(base) / folder).resolve()
|
||||
if must_exist and context.get("check_paths", True) and not folder.is_dir():
|
||||
raise ValueError(f"no such directory: {folder}")
|
||||
return folder
|
||||
|
||||
|
||||
class MqttConfig(_Strict):
|
||||
server: str
|
||||
port: int = Field(default=1883, ge=1, le=65535)
|
||||
@@ -113,18 +131,63 @@ class MqttConfig(_Strict):
|
||||
reconnect_interval: float = Field(default=10.0, gt=0)
|
||||
|
||||
|
||||
class GeneralConfig(_Strict):
|
||||
#: Root folder holding one subfolder per figure.
|
||||
figure_folder: Path
|
||||
class LibraryConfig(_Strict):
|
||||
"""Where the music lives.
|
||||
|
||||
serial_port: str = "/dev/ttyUSB0"
|
||||
One path. The shelves underneath it - ``Figuren``, ``Musik``, ``Hoerbuecher``,
|
||||
``Kinderpodcasts`` - are fixed names, not settings; see
|
||||
:mod:`musicmouse.library.sections`.
|
||||
"""
|
||||
|
||||
root: Path
|
||||
#: Scan results, extracted cover art and track analysis. Relative to this file.
|
||||
cache: Path = Path(".musicmouse-cache")
|
||||
|
||||
@field_validator("root")
|
||||
@classmethod
|
||||
def _resolve_root(cls, folder: Path, info: ValidationInfo) -> Path:
|
||||
return _resolve_folder(folder, info, must_exist=True)
|
||||
|
||||
@field_validator("cache")
|
||||
@classmethod
|
||||
def _resolve_cache(cls, folder: Path, info: ValidationInfo) -> Path:
|
||||
return _resolve_folder(folder, info, must_exist=False)
|
||||
|
||||
@property
|
||||
def figure_folder(self) -> Path:
|
||||
return self.root / "Figuren"
|
||||
|
||||
|
||||
class WebConfig(_Strict):
|
||||
"""The web front-end. Omit the whole section to run without it."""
|
||||
|
||||
#: A LAN appliance with no auth; binding to all interfaces is the point.
|
||||
host: str = "0.0.0.0"
|
||||
port: int = Field(default=8080, ge=1, le=65535)
|
||||
#: Built frontend to serve at ``/``. Omit to expose only the JSON API.
|
||||
static_dir: Path | None = None
|
||||
|
||||
@field_validator("static_dir")
|
||||
@classmethod
|
||||
def _resolve_static(cls, folder: Path | None, info: ValidationInfo) -> Path | None:
|
||||
return None if folder is None else _resolve_folder(folder, info, must_exist=False)
|
||||
|
||||
|
||||
class GeneralConfig(_Strict):
|
||||
library: LibraryConfig
|
||||
|
||||
#: Serial port the ESP32 is on, or ``"simulate"`` to run without the mouse: the
|
||||
#: web front-end is a complete way to drive the player on its own. Required.
|
||||
serial_port: str
|
||||
baudrate: int = Field(default=115200, gt=0)
|
||||
reconnect_interval: float = Field(default=5.0, gt=0)
|
||||
|
||||
#: ALSA output device passed to VLC, e.g. "hw:0,0"; null for VLC's default.
|
||||
alsa_device: str | None = None
|
||||
#: ALSA output device passed to VLC, e.g. ``"hw:0,0"`` or ``"default"``, or
|
||||
#: ``"simulate"`` for a player that makes no sound. Required.
|
||||
alsa_device: str
|
||||
|
||||
mqtt: MqttConfig | None = None
|
||||
web: WebConfig | None = None
|
||||
|
||||
min_volume: int = Field(default=0, ge=0, le=200)
|
||||
max_volume: int = Field(default=100, ge=0, le=200)
|
||||
@@ -134,6 +197,14 @@ class GeneralConfig(_Strict):
|
||||
|
||||
audio_extensions: tuple[str, ...] = DEFAULT_AUDIO_EXTENSIONS
|
||||
|
||||
@property
|
||||
def serial_simulated(self) -> bool:
|
||||
return self.serial_port == SIMULATE
|
||||
|
||||
@property
|
||||
def audio_simulated(self) -> bool:
|
||||
return self.alsa_device == SIMULATE
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_volumes(self) -> Self:
|
||||
if self.min_volume > self.max_volume:
|
||||
@@ -147,22 +218,15 @@ class GeneralConfig(_Strict):
|
||||
)
|
||||
return self
|
||||
|
||||
@field_validator("figure_folder")
|
||||
@classmethod
|
||||
def _resolve_figure_folder(cls, folder: Path, info: ValidationInfo) -> Path:
|
||||
context = info.context or {}
|
||||
base = context.get("config_dir")
|
||||
if base is not None and not folder.is_absolute():
|
||||
folder = (Path(base) / folder).resolve()
|
||||
if context.get("check_paths", True) and not folder.is_dir():
|
||||
raise ValueError(f"no such directory: {folder}")
|
||||
return folder
|
||||
|
||||
|
||||
class FigureConfig(_Strict):
|
||||
#: RFID tag id as hex, e.g. "04a1b2c3d4".
|
||||
id: TagId
|
||||
colors: FigureColors
|
||||
#: What this figure holds. Unlike the other shelves a figure folder is named after
|
||||
#: the figurine rather than its contents, so nothing on disk says whether it is an
|
||||
#: album or an audiobook - and the browse view draws the two differently.
|
||||
kind: Literal["music", "book"] = "music"
|
||||
|
||||
|
||||
class Config(_Strict):
|
||||
@@ -185,16 +249,13 @@ class Config(_Strict):
|
||||
"""Tag id -> figure name, as handed to the device."""
|
||||
return {figure.id: name for name, figure in self.figures.items()}
|
||||
|
||||
@property
|
||||
def figure_kinds(self) -> dict[str, Literal["music", "book"]]:
|
||||
"""Figure name -> what it holds, as handed to the library scanner."""
|
||||
return {name: figure.kind for name, figure in self.figures.items()}
|
||||
|
||||
def folder_for(self, figure: str) -> Path:
|
||||
return self.general.figure_folder / figure
|
||||
|
||||
|
||||
def build_playlists(config: Config) -> dict[str, Playlist]:
|
||||
"""One playlist per figure, from ``<figure_folder>/<figure_name>``, alphabetically."""
|
||||
return {
|
||||
name: build_playlist(name, config.folder_for(name), config.general.audio_extensions)
|
||||
for name in config.figures
|
||||
}
|
||||
return self.general.library.figure_folder / figure
|
||||
|
||||
|
||||
def format_validation_error(error: ValidationError) -> str:
|
||||
@@ -209,6 +270,8 @@ def format_validation_error(error: ValidationError) -> str:
|
||||
message = message.removeprefix(prefix)
|
||||
if entry["type"] == "extra_forbidden":
|
||||
message = "unknown option (check the spelling against config.yml.example)"
|
||||
elif entry["type"] == "missing":
|
||||
message = "required (see config.yml.example)"
|
||||
lines.append(f" {location or '<root>'}: {message}")
|
||||
plural = "s" if len(lines) != 1 else ""
|
||||
return f"{len(lines)} problem{plural} in the config file:\n" + "\n".join(lines)
|
||||
|
||||
Reference in New Issue
Block a user