From 9eaa330c3ee949ea086a96c1a3920baa167adf5d Mon Sep 17 00:00:00 2001 From: Jage9 Date: Wed, 25 Feb 2026 00:52:28 -0500 Subject: [PATCH] Add radio now-playing metadata polling and readonly props --- client/public/version.js | 2 +- client/src/items/itemRegistry.ts | 6 ++ docs/item-schema.md | 5 + docs/item-types.md | 4 + docs/protocol-notes.md | 2 + .../items/types/radio_station/definition.py | 7 ++ .../items/types/radio_station/validator.py | 3 + server/app/server.py | 101 ++++++++++++++++++ server/tests/test_radio_station_validator.py | 24 +++++ server/tests/test_server_message_handling.py | 69 +++++++++++- 10 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 server/tests/test_radio_station_validator.py diff --git a/client/public/version.js b/client/public/version.js index 9910c97..d6401f7 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // 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. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index 40b8493..157f001 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -308,6 +308,12 @@ export function isItemPropertyVisible(item: WorldItem, key: string): boolean { const actual = item.params[conditionKey] ?? getItemTypeGlobalProperties(item.type)[conditionKey]; + if (typeof expected === 'string' && expected.startsWith('!')) { + if (String(actual) === expected.slice(1)) { + return false; + } + continue; + } if (actual !== expected) { return false; } diff --git a/docs/item-schema.md b/docs/item-schema.md index a2e63f1..234260f 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -65,6 +65,8 @@ "mediaVolume": 50, "mediaEffect": "off", "mediaEffectValue": 50, + "stationName": "", + "nowPlaying": "", "facing": 0, "emitRange": 10 } @@ -77,6 +79,9 @@ - `mediaChannel`: one of `stereo | mono | left | right`, default `stereo`. - `mediaEffect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`. - `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`). - UI visibility: `facing` is shown only when `directional=true` (`visibleWhen` metadata). - `emitRange`: integer, range `5-20`, default `10`. diff --git a/docs/item-types.md b/docs/item-types.md index 22c95ed..2a47d84 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -25,6 +25,8 @@ This is behavior-focused documentation for item types and their defaults. - `mediaVolume=50` - `mediaEffect="off"` - `mediaEffectValue=50` + - `stationName=""` (server-managed, read-only) + - `nowPlaying=""` (server-managed, read-only) - `facing=0` - `emitRange=10` - Global: @@ -42,8 +44,10 @@ This is behavior-focused documentation for item types and their defaults. - `mediaVolume`: integer `0..100` - `mediaEffect`: `reverb | echo | flanger | high_pass | low_pass | off` - `mediaEffectValue`: number `0..100` with `0.1` precision + - Visible only when `mediaEffect != off` (`visibleWhen: {"mediaEffect": "!off"}`) - `facing`: number `0..360` with step `1` - `emitRange`: integer `5..20` +- `stationName` / `nowPlaying`: server-fetched metadata fields; not editable by clients. ## `dice` diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 0612611..725bcf4 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -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. - For carried items, source coordinates resolve to the carrier's current position. - `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: - `itemId`, `senderId`, `keyId`, `midi`, `on` - 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`) - 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. +- `visibleWhen` supports equality checks and string negation via `!` prefix (example: `{"mediaEffect": "!off"}`). ## Validation Boundaries diff --git a/server/app/items/types/radio_station/definition.py b/server/app/items/types/radio_station/definition.py index 33170a9..66eeb8c 100644 --- a/server/app/items/types/radio_station/definition.py +++ b/server/app/items/types/radio_station/definition.py @@ -29,6 +29,8 @@ DEFAULT_PARAMS: dict = { "mediaChannel": "stereo", "mediaEffect": "off", "mediaEffectValue": 50, + "stationName": "", + "nowPlaying": "", "facing": 0, "emitRange": 10, } @@ -39,6 +41,8 @@ PARAM_KEYS: tuple[str, ...] = ( "mediaChannel", "mediaEffect", "mediaEffectValue", + "stationName", + "nowPlaying", "facing", "emitRange", ) @@ -61,7 +65,10 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = { "valueType": "number", "tooltip": "Amount for the selected effect.", "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": { "valueType": "number", "tooltip": "Facing direction in degrees used for directional emit.", diff --git a/server/app/items/types/radio_station/validator.py b/server/app/items/types/radio_station/validator.py index 4c24e61..0fd9983 100644 --- a/server/app/items/types/radio_station/validator.py +++ b/server/app/items/types/radio_station/validator.py @@ -59,6 +59,9 @@ def validate_update(item: WorldItem, next_params: dict) -> dict: if not (0 <= effect_value <= 100): raise ValueError("mediaEffectValue must be between 0 and 100.") 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: facing = float(next_params.get("facing", item.params.get("facing", 0))) diff --git a/server/app/server.py b/server/app/server.py index d3d34ee..18e60e9 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse import asyncio from collections import deque +from contextlib import suppress from datetime import datetime from getpass import getpass from importlib.metadata import PackageNotFoundError, version as package_version @@ -18,6 +19,8 @@ import time import uuid from pathlib import Path from typing import Literal +from urllib.error import URLError +from urllib.request import Request, urlopen from zoneinfo import ZoneInfo from pydantic import ValidationError, TypeAdapter @@ -96,6 +99,8 @@ AUTH_RATE_LIMIT_PER_IP = 20 AUTH_RATE_LIMIT_PER_IDENTITY = 8 AUTH_FAILURE_JITTER_MIN_MS = 0.02 AUTH_FAILURE_JITTER_MAX_MS = 0.08 +RADIO_METADATA_POLL_INTERVAL_S = 10.0 +RADIO_METADATA_TIMEOUT_S = 6.0 class SignalingServer: @@ -153,6 +158,7 @@ class SignalingServer: self._auth_hash_semaphore = asyncio.Semaphore(AUTH_HASH_MAX_CONCURRENCY) self._auth_failures_by_ip: dict[str, deque[float]] = {} self._auth_failures_by_identity: dict[str, deque[float]] = {} + self._radio_metadata_task: asyncio.Task[None] | None = None @staticmethod def _resolve_server_version() -> str: @@ -344,6 +350,95 @@ class SignalingServer: return item.useSound.strip() 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]: """Resolve source position for item-emitted one-shot sounds.""" @@ -836,6 +931,7 @@ class SignalingServer: protocol = "wss" if self._ssl_context else "ws" 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: async with serve( self._handle_client, @@ -846,6 +942,11 @@ class SignalingServer: ): await asyncio.Future() 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.auth_service.close() diff --git a/server/tests/test_radio_station_validator.py b/server/tests/test_radio_station_validator.py new file mode 100644 index 0000000..e8ad11d --- /dev/null +++ b/server/tests/test_radio_station_validator.py @@ -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" diff --git a/server/tests/test_server_message_handling.py b/server/tests/test_server_message_handling.py index bdac598..970aba5 100644 --- a/server/tests/test_server_message_handling.py +++ b/server/tests/test_server_message_handling.py @@ -4,6 +4,7 @@ import asyncio import json from time import monotonic from typing import cast +import uuid import pytest 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 == [] +@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 async def test_auth_login_uses_hash_offload(monkeypatch: pytest.MonkeyPatch) -> 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() 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, "_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 auth_results = [packet for packet in send_payloads if getattr(packet, "type", "") == "auth_result"]