Web frontend
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user