Files
musicmouse/python-backend/musicmouse/services/web/api.py
Martin Bauer 2e0e6ad199 Add librosa beat/mood analysis and an Ambience background driven by it
The background worker now runs a real librosa analyzer (tempo, beat grid,
per-second energy/valence curves) instead of only the null baseline, kept
behind build_analyzer() so a plain checkout without the analysis extra still
runs fine. The web player reads that per-track analysis and drives a new
animated "Ambience" background (bubbles, colour, current) that reacts to the
beat and the mood curve as the track plays, plus a debug overlay for tuning
it. Also adds a one-off script to backfill podcast cover art from iTunes.
2026-09-10 22:42:51 +02:00

254 lines
9.3 KiB
Python

"""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 typing import Any
import httpx2
from fastapi import APIRouter, HTTPException, Response, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse
from musicmouse.app import App
from musicmouse.config import HaConfig
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,
HaConfigOut,
HaDeviceOut,
LibraryOut,
PlayerStateOut,
PlayIn,
SeekIn,
SettingsIn,
SettingsOut,
TrackCurvesOut,
TrackDetailOut,
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, ha_client: httpx2.AsyncClient
) -> 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) -> TrackDetailOut:
grid = app.library.beats(album_id, index)
curve = app.library.curve(album_id, index)
if grid is None and curve is None:
raise HTTPException(status_code=404, detail="not analyzed")
return TrackDetailOut(
times=list(grid.times) if grid else [],
strengths=list(grid.strengths) if grid else [],
curve=TrackCurvesOut(**curve.to_json()) if curve else None,
)
@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)
# --------------------------------------------------------------- room control
#
# The browser never sees the Home Assistant token: it stays server-side, attached
# to every proxied request below. The browser only gets to know entity ids and
# display names (get_ha_config) and can ask this backend to relay a states read or
# a service call - the same shape of access the token itself grants, just without
# ever leaving the LAN device. Entity ids are not restricted to the configured
# list; that would only stop someone who already has enough access to open this
# unauthenticated API from asking Home Assistant about a different entity, which
# matches the rest of this API's "trusted LAN device" threat model.
def _require_ha() -> HaConfig:
ha = app.config.general.ha
if ha is None:
raise HTTPException(status_code=404, detail="ha not configured")
return ha
async def _proxy(method: str, path: str, ha: HaConfig, **kwargs: Any) -> Response:
try:
upstream = await ha_client.request(
method, f"{ha.url}{path}", headers={"Authorization": f"Bearer {ha.token}"}, **kwargs
)
except httpx2.HTTPError as exc:
raise HTTPException(
status_code=502, detail=f"Home Assistant unreachable: {exc}"
) from exc
return Response(
content=upstream.content,
status_code=upstream.status_code,
media_type=upstream.headers.get("content-type", "application/json"),
)
@router.get("/ha")
def get_ha_config() -> HaConfigOut:
ha = _require_ha()
return HaConfigOut(
devices=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.devices],
scenes=[HaDeviceOut(entity_id=d.entity_id, name=d.name) for d in ha.scenes],
)
@router.get("/ha/states/{entity_id}")
async def get_ha_state(entity_id: str) -> Response:
return await _proxy("GET", f"/api/states/{entity_id}", _require_ha())
@router.post("/ha/services/{domain}/{service}")
async def call_ha_service(domain: str, service: str, body: dict[str, Any]) -> Response:
return await _proxy("POST", f"/api/services/{domain}/{service}", _require_ha(), json=body)
return router