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.
254 lines
9.3 KiB
Python
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
|