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

@@ -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.",

View File

@@ -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)))

View File

@@ -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()

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
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"]