Add radio now-playing metadata polling and readonly props

This commit is contained in:
Jage9
2026-02-25 00:52:28 -05:00
parent 1745915ec3
commit 9eaa330c3e
10 changed files with 220 additions and 3 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
window.CHGRID_WEB_VERSION = "2026.02.25 R257"; window.CHGRID_WEB_VERSION = "2026.02.25 R258";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -308,6 +308,12 @@ export function isItemPropertyVisible(item: WorldItem, key: string): boolean {
const actual = const actual =
item.params[conditionKey] ?? item.params[conditionKey] ??
getItemTypeGlobalProperties(item.type)[conditionKey]; getItemTypeGlobalProperties(item.type)[conditionKey];
if (typeof expected === 'string' && expected.startsWith('!')) {
if (String(actual) === expected.slice(1)) {
return false;
}
continue;
}
if (actual !== expected) { if (actual !== expected) {
return false; return false;
} }

View File

@@ -65,6 +65,8 @@
"mediaVolume": 50, "mediaVolume": 50,
"mediaEffect": "off", "mediaEffect": "off",
"mediaEffectValue": 50, "mediaEffectValue": 50,
"stationName": "",
"nowPlaying": "",
"facing": 0, "facing": 0,
"emitRange": 10 "emitRange": 10
} }
@@ -77,6 +79,9 @@
- `mediaChannel`: one of `stereo | mono | left | right`, default `stereo`. - `mediaChannel`: one of `stereo | mono | left | right`, default `stereo`.
- `mediaEffect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`. - `mediaEffect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`.
- `mediaEffectValue`: number, range `0-100`, precision `0.1`. - `mediaEffectValue`: number, range `0-100`, precision `0.1`.
- UI visibility: `mediaEffectValue` is shown only when `mediaEffect != off` (`visibleWhen: {"mediaEffect": "!off"}`).
- `stationName`: server-managed station label derived from ICY metadata when available.
- `nowPlaying`: server-managed stream title derived from ICY metadata when available.
- `facing`: number, range `0-360`, step `1` (used when `directional=true`). - `facing`: number, range `0-360`, step `1` (used when `directional=true`).
- UI visibility: `facing` is shown only when `directional=true` (`visibleWhen` metadata). - UI visibility: `facing` is shown only when `directional=true` (`visibleWhen` metadata).
- `emitRange`: integer, range `5-20`, default `10`. - `emitRange`: integer, range `5-20`, default `10`.

View File

@@ -25,6 +25,8 @@ This is behavior-focused documentation for item types and their defaults.
- `mediaVolume=50` - `mediaVolume=50`
- `mediaEffect="off"` - `mediaEffect="off"`
- `mediaEffectValue=50` - `mediaEffectValue=50`
- `stationName=""` (server-managed, read-only)
- `nowPlaying=""` (server-managed, read-only)
- `facing=0` - `facing=0`
- `emitRange=10` - `emitRange=10`
- Global: - Global:
@@ -42,8 +44,10 @@ This is behavior-focused documentation for item types and their defaults.
- `mediaVolume`: integer `0..100` - `mediaVolume`: integer `0..100`
- `mediaEffect`: `reverb | echo | flanger | high_pass | low_pass | off` - `mediaEffect`: `reverb | echo | flanger | high_pass | low_pass | off`
- `mediaEffectValue`: number `0..100` with `0.1` precision - `mediaEffectValue`: number `0..100` with `0.1` precision
- Visible only when `mediaEffect != off` (`visibleWhen: {"mediaEffect": "!off"}`)
- `facing`: number `0..360` with step `1` - `facing`: number `0..360` with step `1`
- `emitRange`: integer `5..20` - `emitRange`: integer `5..20`
- `stationName` / `nowPlaying`: server-fetched metadata fields; not editable by clients.
## `dice` ## `dice`

View File

@@ -50,6 +50,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `item_use_sound` contains absolute item world coordinates (`x`, `y`) and sound path. - `item_use_sound` contains absolute item world coordinates (`x`, `y`) and sound path.
- For carried items, source coordinates resolve to the carrier's current position. - For carried items, source coordinates resolve to the carrier's current position.
- `teleport_complete` contains absolute player world coordinates (`x`, `y`) at teleport landing. - `teleport_complete` contains absolute player world coordinates (`x`, `y`) at teleport landing.
- Radio metadata (`params.stationName`, `params.nowPlaying`) is server-managed and delivered through normal `item_upsert` updates.
- `item_piano_note` contains: - `item_piano_note` contains:
- `itemId`, `senderId`, `keyId`, `midi`, `on` - `itemId`, `senderId`, `keyId`, `midi`, `on`
- resolved `instrument`, `voiceMode`, `octave`, `attack`, `decay`, `release`, `brightness`, `emitRange` - resolved `instrument`, `voiceMode`, `octave`, `attack`, `decay`, `release`, `brightness`, `emitRange`
@@ -81,6 +82,7 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`) - `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`)
- Client item UI requires this metadata from the server; there is no fallback item definition map. - Client item UI requires this metadata from the server; there is no fallback item definition map.
- Client property help/type rendering is metadata-driven; it does not infer fallback types/tooltips from hardcoded key heuristics. - Client property help/type rendering is metadata-driven; it does not infer fallback types/tooltips from hardcoded key heuristics.
- `visibleWhen` supports equality checks and string negation via `!` prefix (example: `{"mediaEffect": "!off"}`).
## Validation Boundaries ## Validation Boundaries

View File

@@ -29,6 +29,8 @@ DEFAULT_PARAMS: dict = {
"mediaChannel": "stereo", "mediaChannel": "stereo",
"mediaEffect": "off", "mediaEffect": "off",
"mediaEffectValue": 50, "mediaEffectValue": 50,
"stationName": "",
"nowPlaying": "",
"facing": 0, "facing": 0,
"emitRange": 10, "emitRange": 10,
} }
@@ -39,6 +41,8 @@ PARAM_KEYS: tuple[str, ...] = (
"mediaChannel", "mediaChannel",
"mediaEffect", "mediaEffect",
"mediaEffectValue", "mediaEffectValue",
"stationName",
"nowPlaying",
"facing", "facing",
"emitRange", "emitRange",
) )
@@ -61,7 +65,10 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
"valueType": "number", "valueType": "number",
"tooltip": "Amount for the selected effect.", "tooltip": "Amount for the selected effect.",
"range": {"min": 0, "max": 100, "step": 0.1}, "range": {"min": 0, "max": 100, "step": 0.1},
"visibleWhen": {"mediaEffect": "!off"},
}, },
"stationName": {"valueType": "text", "tooltip": "Detected station name from stream metadata."},
"nowPlaying": {"valueType": "text", "tooltip": "Detected current track/title from stream metadata."},
"facing": { "facing": {
"valueType": "number", "valueType": "number",
"tooltip": "Facing direction in degrees used for directional emit.", "tooltip": "Facing direction in degrees used for directional emit.",

View File

@@ -59,6 +59,9 @@ def validate_update(item: WorldItem, next_params: dict) -> dict:
if not (0 <= effect_value <= 100): if not (0 <= effect_value <= 100):
raise ValueError("mediaEffectValue must be between 0 and 100.") raise ValueError("mediaEffectValue must be between 0 and 100.")
next_params["mediaEffectValue"] = round(effect_value, 1) next_params["mediaEffectValue"] = round(effect_value, 1)
# Read-only metadata fields are server-managed and cannot be client-edited.
next_params["stationName"] = str(item.params.get("stationName", "")).strip()[:160]
next_params["nowPlaying"] = str(item.params.get("nowPlaying", "")).strip()[:200]
try: try:
facing = float(next_params.get("facing", item.params.get("facing", 0))) facing = float(next_params.get("facing", item.params.get("facing", 0)))

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
from collections import deque from collections import deque
from contextlib import suppress
from datetime import datetime from datetime import datetime
from getpass import getpass from getpass import getpass
from importlib.metadata import PackageNotFoundError, version as package_version from importlib.metadata import PackageNotFoundError, version as package_version
@@ -18,6 +19,8 @@ import time
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from urllib.error import URLError
from urllib.request import Request, urlopen
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from pydantic import ValidationError, TypeAdapter from pydantic import ValidationError, TypeAdapter
@@ -96,6 +99,8 @@ AUTH_RATE_LIMIT_PER_IP = 20
AUTH_RATE_LIMIT_PER_IDENTITY = 8 AUTH_RATE_LIMIT_PER_IDENTITY = 8
AUTH_FAILURE_JITTER_MIN_MS = 0.02 AUTH_FAILURE_JITTER_MIN_MS = 0.02
AUTH_FAILURE_JITTER_MAX_MS = 0.08 AUTH_FAILURE_JITTER_MAX_MS = 0.08
RADIO_METADATA_POLL_INTERVAL_S = 10.0
RADIO_METADATA_TIMEOUT_S = 6.0
class SignalingServer: class SignalingServer:
@@ -153,6 +158,7 @@ class SignalingServer:
self._auth_hash_semaphore = asyncio.Semaphore(AUTH_HASH_MAX_CONCURRENCY) self._auth_hash_semaphore = asyncio.Semaphore(AUTH_HASH_MAX_CONCURRENCY)
self._auth_failures_by_ip: dict[str, deque[float]] = {} self._auth_failures_by_ip: dict[str, deque[float]] = {}
self._auth_failures_by_identity: dict[str, deque[float]] = {} self._auth_failures_by_identity: dict[str, deque[float]] = {}
self._radio_metadata_task: asyncio.Task[None] | None = None
@staticmethod @staticmethod
def _resolve_server_version() -> str: def _resolve_server_version() -> str:
@@ -344,6 +350,95 @@ class SignalingServer:
return item.useSound.strip() return item.useSound.strip()
return None return None
def _get_item_emit_range(self, item: WorldItem) -> int:
"""Return effective emit range for one item with sane bounds."""
value = item.params.get("emitRange")
if isinstance(value, (int, float)):
emit_range = int(value)
if emit_range > 0:
return emit_range
definition = get_item_definition(item.type)
if isinstance(definition.emit_range, int) and definition.emit_range > 0:
return definition.emit_range
return 15
def _has_listener_in_range(self, item: WorldItem) -> bool:
"""Return whether any connected user is currently inside item hear range."""
emit_range = self._get_item_emit_range(item)
for client in self.clients.values():
if max(abs(client.x - item.x), abs(client.y - item.y)) <= emit_range:
return True
return False
@staticmethod
def _fetch_stream_metadata(stream_url: str) -> tuple[str, str]:
"""Read ICY headers/metadata from a stream URL and return station/title."""
if not stream_url:
return "", ""
try:
request = Request(
stream_url,
headers={"Icy-MetaData": "1", "User-Agent": "ChatGrid"},
)
with urlopen(request, timeout=RADIO_METADATA_TIMEOUT_S) as response:
station = str(response.headers.get("icy-name") or response.headers.get("ice-name") or "").strip()
title = ""
metaint_raw = response.headers.get("icy-metaint")
if metaint_raw:
metaint = int(metaint_raw)
if metaint > 0:
response.read(metaint)
meta_len_byte = response.read(1)
if meta_len_byte:
meta_length = meta_len_byte[0] * 16
if meta_length > 0:
meta = response.read(meta_length).decode(errors="ignore")
match = re.search(r"StreamTitle='(.*?)';", meta)
if match:
title = match.group(1).strip()
return station[:160], title[:200]
except (OSError, URLError, ValueError):
return "", ""
async def _refresh_radio_metadata_once(self) -> None:
"""Refresh station/title metadata for active radios near at least one listener."""
radios = [
item
for item in self.items.values()
if item.type == "radio_station"
and bool(item.params.get("enabled", True))
and isinstance(item.params.get("streamUrl"), str)
and str(item.params.get("streamUrl", "")).strip()
and self._has_listener_in_range(item)
]
for item in radios:
stream_url = str(item.params.get("streamUrl", "")).strip()
station_name, now_playing = await asyncio.to_thread(self._fetch_stream_metadata, stream_url)
current_station = str(item.params.get("stationName", "")).strip()
current_playing = str(item.params.get("nowPlaying", "")).strip()
if station_name == current_station and now_playing == current_playing:
continue
item.params["stationName"] = station_name
item.params["nowPlaying"] = now_playing
item.updatedAt = self.item_service.now_ms()
item.version += 1
self._request_state_save()
await self._broadcast_item(item)
async def _run_radio_metadata_loop(self) -> None:
"""Background polling loop that refreshes radio now-playing metadata."""
try:
while True:
await self._refresh_radio_metadata_once()
await asyncio.sleep(RADIO_METADATA_POLL_INTERVAL_S)
except asyncio.CancelledError:
return
def _get_item_sound_source_position(self, item: WorldItem) -> tuple[int, int]: def _get_item_sound_source_position(self, item: WorldItem) -> tuple[int, int]:
"""Resolve source position for item-emitted one-shot sounds.""" """Resolve source position for item-emitted one-shot sounds."""
@@ -836,6 +931,7 @@ class SignalingServer:
protocol = "wss" if self._ssl_context else "ws" protocol = "wss" if self._ssl_context else "ws"
LOGGER.info("starting signaling server on %s://%s:%d", protocol, self.host, self.port) LOGGER.info("starting signaling server on %s://%s:%d", protocol, self.host, self.port)
self._radio_metadata_task = asyncio.create_task(self._run_radio_metadata_loop())
try: try:
async with serve( async with serve(
self._handle_client, self._handle_client,
@@ -846,6 +942,11 @@ class SignalingServer:
): ):
await asyncio.Future() await asyncio.Future()
finally: finally:
if self._radio_metadata_task is not None:
self._radio_metadata_task.cancel()
with suppress(asyncio.CancelledError):
await self._radio_metadata_task
self._radio_metadata_task = None
self._flush_state_save() self._flush_state_save()
self.auth_service.close() self.auth_service.close()

View File

@@ -0,0 +1,24 @@
from __future__ import annotations
from app.client import ClientConnection
from app.item_service import ItemService
from app.items.types.radio_station.validator import validate_update
class _Ws:
pass
def test_radio_validator_preserves_readonly_metadata_fields(tmp_path) -> None:
service = ItemService(state_file=tmp_path / "items.json")
client = ClientConnection(websocket=_Ws(), id="u1", nickname="tester")
item = service.default_item(client, "radio_station")
item.params["stationName"] = "Original Station"
item.params["nowPlaying"] = "Original Song"
next_params = {**item.params, "stationName": "Injected", "nowPlaying": "Injected Song", "mediaVolume": 60}
validated = validate_update(item, next_params)
assert validated["mediaVolume"] == 60
assert validated["stationName"] == "Original Station"
assert validated["nowPlaying"] == "Original Song"

View File

@@ -4,6 +4,7 @@ import asyncio
import json import json
from time import monotonic from time import monotonic
from typing import cast from typing import cast
import uuid
import pytest import pytest
from websockets.asyncio.server import ServerConnection from websockets.asyncio.server import ServerConnection
@@ -41,10 +42,71 @@ async def test_update_position_rejects_out_of_bounds(monkeypatch: pytest.MonkeyP
assert broadcast_payloads == [] assert broadcast_payloads == []
@pytest.mark.asyncio
async def test_radio_metadata_refresh_updates_station_and_title(monkeypatch: pytest.MonkeyPatch) -> None:
server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41)
ws = _fake_ws()
client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=10, y=10)
server.clients[ws] = client
radio = server.item_service.default_item(client, "radio_station")
radio.params["streamUrl"] = "http://example.com/stream"
radio.params["enabled"] = True
radio.params["emitRange"] = 10
radio.params["stationName"] = ""
radio.params["nowPlaying"] = ""
server.item_service.add_item(radio)
async def fake_broadcast_item(item: object) -> None:
return None
def fake_fetch(url: str) -> tuple[str, str]:
assert url == "http://example.com/stream"
return ("Test Station", "Test Song")
monkeypatch.setattr(server, "_broadcast_item", fake_broadcast_item)
monkeypatch.setattr(server, "_fetch_stream_metadata", fake_fetch)
await server._refresh_radio_metadata_once()
assert radio.params["stationName"] == "Test Station"
assert radio.params["nowPlaying"] == "Test Song"
@pytest.mark.asyncio
async def test_radio_metadata_refresh_skips_when_no_listener_in_range(monkeypatch: pytest.MonkeyPatch) -> None:
server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41)
ws = _fake_ws()
client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=0, y=0)
server.clients[ws] = client
radio = server.item_service.default_item(client, "radio_station")
radio.x = 30
radio.y = 30
radio.params["streamUrl"] = "http://example.com/stream"
radio.params["enabled"] = True
radio.params["emitRange"] = 5
server.item_service.add_item(radio)
called = False
def fake_fetch(url: str) -> tuple[str, str]:
nonlocal called
called = True
return ("X", "Y")
monkeypatch.setattr(server, "_fetch_stream_metadata", fake_fetch)
await server._refresh_radio_metadata_once()
assert called is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_auth_login_uses_hash_offload(monkeypatch: pytest.MonkeyPatch) -> None: async def test_auth_login_uses_hash_offload(monkeypatch: pytest.MonkeyPatch) -> None:
server = SignalingServer("127.0.0.1", 8765, None, None) server = SignalingServer("127.0.0.1", 8765, None, None)
server.auth_service.register("alpha", "password99") username = f"alpha_{uuid.uuid4().hex[:8]}"
server.auth_service.register(username, "password99")
ws = _fake_ws() ws = _fake_ws()
client = ClientConnection(websocket=ws, id="u1", nickname="tester") client = ClientConnection(websocket=ws, id="u1", nickname="tester")
@@ -65,7 +127,10 @@ async def test_auth_login_uses_hash_offload(monkeypatch: pytest.MonkeyPatch) ->
monkeypatch.setattr(server, "_broadcast", fake_broadcast) monkeypatch.setattr(server, "_broadcast", fake_broadcast)
monkeypatch.setattr(server, "_run_auth_hash_task", fake_run_auth_hash_task) monkeypatch.setattr(server, "_run_auth_hash_task", fake_run_auth_hash_task)
await server._handle_message(client, json.dumps({"type": "auth_login", "username": "alpha", "password": "password99"})) await server._handle_message(
client,
json.dumps({"type": "auth_login", "username": username, "password": "password99"}),
)
assert "login" in offload_calls assert "login" in offload_calls
auth_results = [packet for packet in send_payloads if getattr(packet, "type", "") == "auth_result"] auth_results = [packet for packet in send_payloads if getattr(packet, "type", "") == "auth_result"]