Add radio now-playing metadata polling and readonly props
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
24
server/tests/test_radio_station_validator.py
Normal file
24
server/tests/test_radio_station_validator.py
Normal 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"
|
||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user