kryten-webqueue 0.23.1__tar.gz → 0.25.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/CHANGELOG.md +17 -0
  2. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/api_gate/client.py +10 -0
  4. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/app.py +9 -0
  5. kryten_webqueue-0.25.0/kryten_webqueue/queue/race_poller.py +91 -0
  6. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/routes/pages.py +7 -0
  7. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/static/css/main.css +40 -0
  8. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/templates/base.html +1 -0
  9. kryten_webqueue-0.25.0/kryten_webqueue/templates/race.html +223 -0
  10. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/templates/user/dashboard.html +96 -2
  11. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/ws/handler.py +45 -0
  12. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/pyproject.toml +1 -1
  13. kryten_webqueue-0.25.0/tests/test_race_poller.py +102 -0
  14. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/.github/workflows/python-publish.yml +0 -0
  15. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/.github/workflows/release.yml +0 -0
  16. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/.gitignore +0 -0
  17. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/README.md +0 -0
  18. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/config.example.json +0 -0
  19. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/deploy/kryten-webqueue.service +0 -0
  20. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/deploy/nginx-queue.conf +0 -0
  21. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  22. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/docs/IMPL_API_GATE.md +0 -0
  23. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/docs/IMPL_ECONOMY.md +0 -0
  24. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  25. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/docs/IMPL_ROBOT.md +0 -0
  26. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  27. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/docs/PRE_PLAN_GAPS.md +0 -0
  28. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/docs/PRODUCT_PLAN.md +0 -0
  29. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  30. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/docs/UX_POLISH_PLAN.md +0 -0
  31. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/__init__.py +0 -0
  32. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/__main__.py +0 -0
  33. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  34. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/auth/__init__.py +0 -0
  35. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/auth/otp.py +0 -0
  36. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  37. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/auth/session.py +0 -0
  38. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/catalog/__init__.py +0 -0
  39. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/catalog/db.py +0 -0
  40. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/catalog/images.py +0 -0
  41. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  42. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/catalog/sync.py +0 -0
  43. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/config.py +0 -0
  44. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/integrations/__init__.py +0 -0
  45. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  46. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  47. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  48. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  49. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  50. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  51. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  52. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  53. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/jobs/__init__.py +0 -0
  54. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  55. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/jobs/manager.py +0 -0
  56. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/jobs/tasks.py +0 -0
  57. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/logging_config.py +0 -0
  58. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/playlists/__init__.py +0 -0
  59. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  60. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/playlists/fire.py +0 -0
  61. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/playlists/importer.py +0 -0
  62. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/playlists/ordering.py +0 -0
  63. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  64. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/promos/__init__.py +0 -0
  65. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/promos/director.py +0 -0
  66. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/queue/__init__.py +0 -0
  67. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/queue/ordering.py +0 -0
  68. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/queue/poller.py +0 -0
  69. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/queue/presence.py +0 -0
  70. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/queue/shadow.py +0 -0
  71. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/routes/__init__.py +0 -0
  72. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  73. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  74. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  75. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  76. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  77. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  78. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/routes/auth.py +0 -0
  79. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/routes/catalog.py +0 -0
  80. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/routes/queue.py +0 -0
  81. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/routes/user.py +0 -0
  82. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/static/js/main.js +0 -0
  83. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/templates/admin/index.html +0 -0
  84. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  85. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  86. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  87. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  88. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/templates/auth/login.html +0 -0
  89. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  90. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  91. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  92. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/templates/queue/index.html +0 -0
  93. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/ws/__init__.py +0 -0
  94. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/kryten_webqueue/ws/manager.py +0 -0
  95. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/__init__.py +0 -0
  96. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_config_persistence.py +0 -0
  97. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_fetchurls_sharepoint.py +0 -0
  98. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_phase1.py +0 -0
  99. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_phase2_jobs.py +0 -0
  100. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_phase3_jobs.py +0 -0
  101. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_phase4_live_fixes.py +0 -0
  102. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_playlist_import.py +0 -0
  103. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_presence_refund.py +0 -0
  104. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_promo_director.py +0 -0
  105. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_promo_pool_exclusion.py +0 -0
  106. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_queue_announce.py +0 -0
  107. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_save_results_to_playlist.py +0 -0
  108. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_schedule_lock.py +0 -0
  109. {kryten_webqueue-0.23.1 → kryten_webqueue-0.25.0}/tests/test_search_facets.py +0 -0
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.25.0] — 2026-06-22
9
+
10
+ ### Added
11
+
12
+ - **Live web race view at `/race`.** The economy's racing game play-by-play moved off public chat (which flooded the channel) and onto a visual web view anyone can watch — no login required. A new public page animates the race in real time: each racer glides along its lane toward the finish, with live positions, odds, the betting countdown, per-colour bet totals, and a winner banner + payouts when it ends. A background `RacePoller` polls api-gate's `GET /economy/race` adaptively (fast while a race is live, backing off when idle) and pushes frames to spectators over a dedicated public `/ws/race` WebSocket; a newly-opened view is immediately shown a race already in progress. The race WebSocket carries only race frames (no user or queue data), and a “Race” link was added to the nav.
13
+
14
+ [0.25.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.25.0
15
+
16
+ ## [0.24.0] — 2026-06-21
17
+
18
+ ### Added
19
+
20
+ - **Live readability preview for the chat-color picker.** The dashboard's chat-color editor now previews the chosen color exactly as it renders in chat — a sample chat line (gray timestamp, username, message) on the near-black chat background (`#111`) — and scores it as you pick, mirroring kryten-economy's server-side guard (combined APCA lightness contrast × red-chroma penalty). A contrast badge reports **ok / warn / reject**: colors that fail the readability threshold (very dark colors and harsh pure reds) show a **reject** badge and the **Save button is disabled**, so users can't buy a color the server would refuse; borderline colors get a non-blocking **warn**. The thresholds (`reject < 30`, `warn < 40`) and background match the economy service so the dashboard and server always agree.
21
+
22
+ [0.24.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.24.0
23
+
7
24
  ## [0.23.1] — 2026-06-21
8
25
 
9
26
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.23.1
3
+ Version: 0.25.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -47,6 +47,16 @@ class ApiGateClient:
47
47
  async def get_user(self, username: str) -> dict:
48
48
  return await self.get(f"/state/user/{username}")
49
49
 
50
+ # --- Economy: race view ---
51
+
52
+ async def get_race_state(self) -> dict:
53
+ """Live race snapshot for the web race view.
54
+
55
+ Returns ``{"active": bool, "frame": {...}|None}`` from api-gate's
56
+ ``GET /economy/race`` (which proxies the economy ``race.state`` command).
57
+ """
58
+ return await self.get("/economy/race")
59
+
50
60
  # --- Playlist CRUD ---
51
61
 
52
62
  async def playlist_add(self, media_type: str, media_id: str, *, position: str = "end", temp: bool = True) -> dict:
@@ -16,6 +16,7 @@ from .jobs import JobManager
16
16
  from .catalog.images import CoverArtResolver
17
17
  from .queue.shadow import QueueShadow
18
18
  from .queue.poller import StatePoller
19
+ from .queue.race_poller import RacePoller
19
20
  from .queue.presence import PresenceRefundMonitor
20
21
  from .promos.director import PromoDirector
21
22
  from .ws.manager import WebSocketManager
@@ -114,6 +115,13 @@ async def lifespan(app: FastAPI):
114
115
  ws_manager = WebSocketManager()
115
116
  app.state.ws_manager = ws_manager
116
117
 
118
+ # Public race-view WebSocket manager (anonymous spectators) + poller.
119
+ race_ws_manager = WebSocketManager()
120
+ app.state.race_ws_manager = race_ws_manager
121
+ race_poller = RacePoller(api_gate=api_gate, ws_manager=race_ws_manager)
122
+ await race_poller.start()
123
+ app.state.race_poller = race_poller
124
+
117
125
  # Queue shadow
118
126
  shadow = QueueShadow(db)
119
127
  await shadow.load_from_db()
@@ -218,6 +226,7 @@ async def lifespan(app: FastAPI):
218
226
  for task in bg_tasks:
219
227
  task.cancel()
220
228
  await poller.stop()
229
+ await race_poller.stop()
221
230
  await scheduler.stop()
222
231
  await presence_monitor.stop()
223
232
  await catalog_sync.close()
@@ -0,0 +1,91 @@
1
+ import asyncio
2
+ import logging
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+
7
+ class RacePoller:
8
+ """Polls api-gate's race view and pushes frames to race spectators.
9
+
10
+ Adaptive cadence: while a race is live it polls quickly (``active_interval``)
11
+ so the web view animates smoothly; when idle it backs off
12
+ (``idle_interval``). The latest frame is cached so a newly-connected
13
+ spectator can be shown the race already in progress.
14
+
15
+ Broadcast messages (to the public race WebSocket manager):
16
+ - ``{"type": "race_frame", "data": <frame>}`` each poll while a race is live.
17
+ - ``{"type": "race_clear"}`` once, when a race ends and the view goes idle.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ *,
23
+ api_gate,
24
+ ws_manager,
25
+ active_interval: float = 1.5,
26
+ idle_interval: float = 4.0,
27
+ ):
28
+ self._api_gate = api_gate
29
+ self._ws_manager = ws_manager
30
+ self._active_interval = active_interval
31
+ self._idle_interval = idle_interval
32
+ self._task: asyncio.Task | None = None
33
+ self._last_frame: dict | None = None
34
+
35
+ @property
36
+ def last_frame(self) -> dict | None:
37
+ """The most recent live frame, or None when no race is active."""
38
+ return self._last_frame
39
+
40
+ async def start(self):
41
+ self._task = asyncio.create_task(self._loop())
42
+ logger.info(
43
+ "RacePoller started (active=%.1fs idle=%.1fs)",
44
+ self._active_interval, self._idle_interval,
45
+ )
46
+
47
+ async def stop(self):
48
+ if self._task:
49
+ self._task.cancel()
50
+ try:
51
+ await self._task
52
+ except asyncio.CancelledError:
53
+ pass
54
+ logger.info("RacePoller stopped")
55
+
56
+ async def _poll_once(self) -> float:
57
+ """Run one poll; broadcast as needed. Returns the next sleep interval."""
58
+ try:
59
+ result = await self._api_gate.get_race_state()
60
+ except asyncio.CancelledError:
61
+ raise
62
+ except Exception as e:
63
+ logger.debug("Race poll error: %s", e)
64
+ # Treat an error like "idle" but don't spuriously clear an active
65
+ # view — just back off and retry.
66
+ return self._idle_interval
67
+
68
+ active = bool(result.get("active")) and result.get("frame") is not None
69
+ if active:
70
+ self._last_frame = result["frame"]
71
+ await self._ws_manager.broadcast(
72
+ {"type": "race_frame", "data": self._last_frame}
73
+ )
74
+ return self._active_interval
75
+
76
+ # Not active. If we were showing a race, tell spectators to clear once.
77
+ if self._last_frame is not None:
78
+ self._last_frame = None
79
+ await self._ws_manager.broadcast({"type": "race_clear"})
80
+ return self._idle_interval
81
+
82
+ async def _loop(self):
83
+ while True:
84
+ try:
85
+ delay = await self._poll_once()
86
+ except asyncio.CancelledError:
87
+ raise
88
+ except Exception as e:
89
+ logger.warning("RacePoller loop error: %s", e)
90
+ delay = self._idle_interval
91
+ await asyncio.sleep(delay)
@@ -71,6 +71,13 @@ async def login_page(request: Request):
71
71
  return templates.TemplateResponse(request, "auth/login.html", {"user": None})
72
72
 
73
73
 
74
+ @router.get("/race", response_class=HTMLResponse)
75
+ async def race_view(request: Request):
76
+ """Public race spectator view — no auth. Streams frames over /ws/race."""
77
+ user = _get_user_or_none(request)
78
+ return templates.TemplateResponse(request, "race.html", {"user": user})
79
+
80
+
74
81
  @router.get("/catalog/browse", response_class=HTMLResponse)
75
82
  async def catalog_browse_page(request: Request, category: str | None = None,
76
83
  tag: str | None = None, page: int = 1,
@@ -1009,6 +1009,46 @@ a.np-chip {
1009
1009
  text-transform: uppercase;
1010
1010
  }
1011
1011
 
1012
+ /* Chat-color live preview + readability badge */
1013
+ .color-preview {
1014
+ background: #111;
1015
+ border: 1px solid var(--border);
1016
+ border-radius: var(--radius);
1017
+ padding: 0.6rem 0.75rem;
1018
+ margin: 0 0 0.6rem;
1019
+ font-size: 0.95rem;
1020
+ line-height: 1.5;
1021
+ }
1022
+ .color-preview .cp-ts { color: #AAA; }
1023
+ .color-preview .cp-name { font-weight: 700; }
1024
+ .color-preview .cp-msg { /* color set inline to the chosen value */ }
1025
+ .contrast-badge {
1026
+ display: flex;
1027
+ align-items: flex-start;
1028
+ gap: 0.5rem;
1029
+ font-size: 0.82rem;
1030
+ padding: 0.5rem 0.6rem;
1031
+ border-radius: var(--radius);
1032
+ margin-bottom: 0.75rem;
1033
+ border: 1px solid var(--border);
1034
+ }
1035
+ .contrast-badge .cb-icon { flex-shrink: 0; line-height: 1.3; }
1036
+ .contrast-badge.cb-ok {
1037
+ background: rgba(0, 184, 148, 0.10);
1038
+ border-color: var(--success, #00b894);
1039
+ color: var(--success, #00b894);
1040
+ }
1041
+ .contrast-badge.cb-warn {
1042
+ background: rgba(253, 203, 110, 0.12);
1043
+ border-color: var(--warning, #fdcb6e);
1044
+ color: var(--warning, #fdcb6e);
1045
+ }
1046
+ .contrast-badge.cb-reject {
1047
+ background: rgba(225, 112, 85, 0.12);
1048
+ border-color: var(--danger, #e17055);
1049
+ color: var(--danger, #e17055);
1050
+ }
1051
+
1012
1052
  /* Transaction filter toggle */
1013
1053
  .tx-toggle {
1014
1054
  display: flex;
@@ -34,6 +34,7 @@
34
34
  <div class="nav-links">
35
35
  <a href="/catalog/browse">Browse</a>
36
36
  <a href="/queue">Queue</a>
37
+ <a href="/race">Race</a>
37
38
  {% if user %}
38
39
  <a href="/user/dashboard">{{ user.username }}</a>
39
40
  {% if user.rank >= 3 %}
@@ -0,0 +1,223 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Live Race{% endblock %}
3
+ {% block body_class %}race-page{% endblock %}
4
+
5
+ {% block head %}
6
+ <style>
7
+ .race-wrap { max-width: 900px; margin: 0 auto; }
8
+ .race-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
9
+ .race-head h1 { font-family: 'Sora', sans-serif; margin: 0; }
10
+ .race-status { font-weight: 600; opacity: 0.85; }
11
+ .race-status .pill { padding: 0.15rem 0.6rem; border-radius: 999px; font-size: 0.8rem; }
12
+ .pill-betting { background: #1f6feb33; color: #79c0ff; }
13
+ .pill-racing { background: #2ea04333; color: #7ee787; }
14
+ .pill-finished{ background: #d2992233; color: #f0c674; }
15
+ .pill-idle { background: #6e768166; color: #c9d1d9; }
16
+
17
+ .race-track { margin: 1.25rem 0; display: flex; flex-direction: column; gap: 0.55rem; }
18
+ .lane {
19
+ position: relative; display: grid; grid-template-columns: 2.4rem 1fr auto;
20
+ align-items: center; gap: 0.6rem; padding: 0.35rem 0.5rem; border-radius: 10px;
21
+ background: #11141a; border: 1px solid #232a33;
22
+ }
23
+ .lane.win { border-color: #f0c674; box-shadow: 0 0 0 1px #f0c67455, 0 0 18px #f0c67433; }
24
+ .lane-pos { font-weight: 700; text-align: center; opacity: 0.8; font-variant-numeric: tabular-nums; }
25
+ .lane-rail { position: relative; height: 26px; background: #0c0e12; border-radius: 6px; overflow: hidden; }
26
+ .lane-fill {
27
+ position: absolute; inset: 0 auto 0 0; width: 0%;
28
+ background: linear-gradient(90deg, #1f6feb44, #1f6feb22);
29
+ transition: width 1.4s linear; border-right: 2px solid #ffffff22;
30
+ }
31
+ .lane-runner {
32
+ position: absolute; top: 50%; left: 0; transform: translate(-50%, -50%);
33
+ font-size: 1.15rem; transition: left 1.4s linear; will-change: left;
34
+ text-shadow: 0 1px 2px #000a;
35
+ }
36
+ .lane-meta { display: flex; align-items: baseline; gap: 0.5rem; }
37
+ .lane-name { font-weight: 600; }
38
+ .lane-odds { font-size: 0.8rem; opacity: 0.7; font-variant-numeric: tabular-nums; }
39
+ .lane-trait { font-size: 0.72rem; opacity: 0.6; }
40
+ .lane-amt { font-size: 0.75rem; opacity: 0.7; font-variant-numeric: tabular-nums; }
41
+
42
+ .race-side { display: flex; gap: 1.5rem; flex-wrap: wrap; font-variant-numeric: tabular-nums; }
43
+ .race-side .stat b { font-size: 1.1rem; }
44
+ .race-winner { margin: 1rem 0; padding: 0.75rem 1rem; border-radius: 10px;
45
+ background: #d2992218; border: 1px solid #f0c67455; }
46
+ .race-winner h2 { margin: 0 0 0.35rem; font-family: 'Sora', sans-serif; }
47
+ .race-payouts { margin: 0.25rem 0 0; padding-left: 1.1rem; }
48
+ .race-idle { text-align: center; opacity: 0.7; padding: 3rem 1rem; }
49
+ .race-finish-flag { position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
50
+ font-size: 0.95rem; opacity: 0.5; }
51
+ </style>
52
+ {% endblock %}
53
+
54
+ {% block content %}
55
+ <div class="race-wrap">
56
+ <div class="race-head">
57
+ <h1>🏁 Live Race</h1>
58
+ <div class="race-status" id="race-status"><span class="pill pill-idle">Connecting…</span></div>
59
+ </div>
60
+
61
+ <div id="race-body">
62
+ <div class="race-idle" id="race-idle">Waiting for the next race…</div>
63
+ <div class="race-track" id="race-track" hidden></div>
64
+ <div class="race-side" id="race-side" hidden></div>
65
+ <div class="race-winner" id="race-winner" hidden></div>
66
+ </div>
67
+ </div>
68
+ {% endblock %}
69
+
70
+ {% block scripts %}
71
+ <script>
72
+ (function () {
73
+ const statusEl = document.getElementById('race-status');
74
+ const idleEl = document.getElementById('race-idle');
75
+ const trackEl = document.getElementById('race-track');
76
+ const sideEl = document.getElementById('race-side');
77
+ const winnerEl = document.getElementById('race-winner');
78
+
79
+ const lanes = new Map(); // color -> { row, fill, runner, pos, amt }
80
+ let laneSig = ''; // signature of the current racer set (to detect changes)
81
+
82
+ function esc(s) {
83
+ return String(s).replace(/[&<>"']/g, c => (
84
+ { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]
85
+ ));
86
+ }
87
+
88
+ function setStatus(kind, text) {
89
+ statusEl.innerHTML = '<span class="pill pill-' + kind + '">' + esc(text) + '</span>';
90
+ }
91
+
92
+ function buildLanes(racers) {
93
+ trackEl.innerHTML = '';
94
+ lanes.clear();
95
+ for (const r of racers) {
96
+ const row = document.createElement('div');
97
+ row.className = 'lane';
98
+ row.innerHTML =
99
+ '<div class="lane-pos"></div>' +
100
+ '<div class="lane-rail">' +
101
+ '<div class="lane-fill"></div>' +
102
+ '<span class="lane-runner">' + esc(r.emoji || '🏃') + '</span>' +
103
+ '<span class="race-finish-flag">🏁</span>' +
104
+ '</div>' +
105
+ '<div class="lane-meta">' +
106
+ '<span class="lane-name">' + esc(r.color) + '</span>' +
107
+ '<span class="lane-odds">' + esc(r.odds || '') + '</span>' +
108
+ (r.trait ? '<span class="lane-trait">' + esc(r.trait) + '</span>' : '') +
109
+ '<span class="lane-amt"></span>' +
110
+ '</div>';
111
+ trackEl.appendChild(row);
112
+ lanes.set(r.color, {
113
+ row,
114
+ fill: row.querySelector('.lane-fill'),
115
+ runner: row.querySelector('.lane-runner'),
116
+ pos: row.querySelector('.lane-pos'),
117
+ amt: row.querySelector('.lane-amt'),
118
+ });
119
+ }
120
+ }
121
+
122
+ function render(frame) {
123
+ idleEl.hidden = true;
124
+ trackEl.hidden = false;
125
+ sideEl.hidden = false;
126
+
127
+ // Rebuild lanes only when the racer set changes (keeps CSS transitions smooth).
128
+ const sig = frame.racers.map(r => r.color).join('|');
129
+ if (sig !== laneSig) { buildLanes(frame.racers); laneSig = sig; }
130
+
131
+ const finished = frame.phase === 'finished';
132
+ const winnerColor = frame.winner && frame.winner.color;
133
+ const betsByColor = frame.bets_by_color || {};
134
+
135
+ for (const r of frame.racers) {
136
+ const lane = lanes.get(r.color);
137
+ if (!lane) continue;
138
+ const pct = Math.max(0, Math.min(100, r.pct || 0));
139
+ lane.fill.style.width = pct + '%';
140
+ lane.runner.style.left = pct + '%';
141
+ lane.pos.textContent = r.position ? ('#' + r.position) : '';
142
+ const bet = betsByColor[r.color];
143
+ lane.amt.textContent = bet ? ('💰 ' + bet.amount.toLocaleString()) : '';
144
+ lane.row.classList.toggle('win', finished && r.color === winnerColor);
145
+ }
146
+
147
+ // Status line
148
+ if (frame.phase === 'betting') {
149
+ setStatus('betting', 'Betting open — ' + (frame.betting_closes_in || 0) + 's left');
150
+ } else if (frame.phase === 'racing') {
151
+ setStatus('racing', 'Racing! Leader: ' + (frame.leader || '—'));
152
+ } else if (finished) {
153
+ setStatus('finished', 'Finished');
154
+ }
155
+
156
+ // Side stats
157
+ const sym = frame.symbol || '';
158
+ sideEl.innerHTML =
159
+ '<div class="stat">Pool <b>' + (frame.pool || 0).toLocaleString() + ' ' + esc(sym) + '</b></div>' +
160
+ '<div class="stat">Bettors <b>' + (frame.bettor_count || 0) + '</b></div>' +
161
+ (frame.phase === 'betting'
162
+ ? '<div class="stat">Min bet <b>' + (frame.min_bet || 0).toLocaleString() + ' ' + esc(sym) + '</b></div>'
163
+ : '');
164
+
165
+ // Winner banner + payouts
166
+ if (finished && frame.winner) {
167
+ const payouts = (frame.payouts || []).slice(0, 5)
168
+ .map(p => '<li>' + esc(p.username) + ' <b>+' + (p.net || 0).toLocaleString() + ' ' + esc(sym) + '</b></li>')
169
+ .join('');
170
+ winnerEl.hidden = false;
171
+ winnerEl.innerHTML =
172
+ '<h2>' + esc(frame.winner.emoji || '🏆') + ' ' + esc(frame.winner.color) + ' wins!</h2>' +
173
+ (payouts ? '<ul class="race-payouts">' + payouts + '</ul>'
174
+ : '<div>No winning bets this race.</div>');
175
+ } else {
176
+ winnerEl.hidden = true;
177
+ }
178
+ }
179
+
180
+ function showIdle() {
181
+ laneSig = '';
182
+ lanes.clear();
183
+ trackEl.hidden = true;
184
+ sideEl.hidden = true;
185
+ winnerEl.hidden = true;
186
+ idleEl.hidden = false;
187
+ setStatus('idle', 'No race running');
188
+ }
189
+
190
+ // ── WebSocket with auto-reconnect ──────────────────────────
191
+ let ws = null, retry = 0, pingTimer = null;
192
+
193
+ function connect() {
194
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
195
+ ws = new WebSocket(proto + '://' + location.host + '/ws/race');
196
+
197
+ ws.onopen = () => {
198
+ retry = 0;
199
+ setStatus('idle', 'Connected');
200
+ clearInterval(pingTimer);
201
+ pingTimer = setInterval(() => {
202
+ try { ws.send(JSON.stringify({ type: 'ping' })); } catch (e) {}
203
+ }, 25000);
204
+ };
205
+ ws.onmessage = (ev) => {
206
+ let m; try { m = JSON.parse(ev.data); } catch (e) { return; }
207
+ if (m.type === 'race_frame') render(m.data);
208
+ else if (m.type === 'race_clear') showIdle();
209
+ };
210
+ ws.onclose = () => {
211
+ clearInterval(pingTimer);
212
+ setStatus('idle', 'Reconnecting…');
213
+ retry = Math.min(retry + 1, 6);
214
+ setTimeout(connect, 500 * retry);
215
+ };
216
+ ws.onerror = () => { try { ws.close(); } catch (e) {} };
217
+ }
218
+
219
+ showIdle();
220
+ connect();
221
+ })();
222
+ </script>
223
+ {% endblock %}
@@ -293,15 +293,92 @@ async function saveGreeting() {
293
293
  }
294
294
  }
295
295
 
296
+ // ── Chat-color readability scoring (mirrors kryten-economy contrast.py) ──
297
+ // The economy server is authoritative and re-checks on purchase; this gives
298
+ // instant local preview/feedback. Defaults match the economy config defaults.
299
+ const CONTRAST_BG = '#111111';
300
+ const CONTRAST_MIN = 30.0; // reject below this combined score
301
+ const CONTRAST_WARN = 40.0; // warn below this
302
+ const CHROMA_KNEE = 160; // green+blue at which the red penalty clears
303
+
304
+ function _hexRgb(h) {
305
+ let s = h.replace('#', '');
306
+ if (s.length === 3) s = s.split('').map(c => c + c).join('');
307
+ return [parseInt(s.slice(0,2),16), parseInt(s.slice(2,4),16), parseInt(s.slice(4,6),16)];
308
+ }
309
+ function _screenY(h) {
310
+ const [r,g,b] = _hexRgb(h);
311
+ return 0.2126729*Math.pow(r/255,2.4) + 0.7151522*Math.pow(g/255,2.4) + 0.0721750*Math.pow(b/255,2.4);
312
+ }
313
+ function _apcaLc(textHex, bgHex) {
314
+ let yt = _screenY(textHex), yb = _screenY(bgHex);
315
+ if (yt <= 0.022) yt += Math.pow(0.022 - yt, 1.414);
316
+ if (yb <= 0.022) yb += Math.pow(0.022 - yb, 1.414);
317
+ if (Math.abs(yb - yt) < 0.0005) return 0;
318
+ let out;
319
+ if (yb > yt) { const s = (Math.pow(yb,0.56)-Math.pow(yt,0.57))*1.14; out = s < 0.1 ? 0 : s - 0.027; }
320
+ else { const s = (Math.pow(yb,0.65)-Math.pow(yt,0.62))*1.14; out = s > -0.1 ? 0 : s + 0.027; }
321
+ return Math.abs(out * 100);
322
+ }
323
+ function _chromaFactor(h) {
324
+ const [, g, b] = _hexRgb(h);
325
+ return Math.min(g + b, CHROMA_KNEE) / CHROMA_KNEE;
326
+ }
327
+ function classifyColor(hex) {
328
+ const lc = _apcaLc(hex, CONTRAST_BG);
329
+ const score = Math.round(lc * _chromaFactor(hex) * 10) / 10;
330
+ let level;
331
+ if (score < CONTRAST_MIN) level = 'reject';
332
+ else if (score < CONTRAST_WARN) level = 'warn';
333
+ else level = 'ok';
334
+ return { score, level };
335
+ }
336
+
337
+ function updateColorPreview(hex) {
338
+ const msg = document.getElementById('cp-msg');
339
+ const sw = document.getElementById('color-input');
340
+ const badge = document.getElementById('contrast-badge');
341
+ const save = document.getElementById('color-save');
342
+ const valid = /^#[0-9A-Fa-f]{6}$/.test(hex);
343
+ if (msg) msg.style.color = valid ? hex : '#FFFFFF';
344
+ if (!valid || !badge || !save) return;
345
+ const { score, level } = classifyColor(hex);
346
+ if (level === 'reject') {
347
+ badge.className = 'contrast-badge cb-reject';
348
+ badge.innerHTML = '<span class="cb-icon">⛔</span><span>Too hard to read on the dark chat background. ' +
349
+ 'Try a lighter shade, or add some green/blue (e.g. pink instead of pure red).</span>';
350
+ save.disabled = true;
351
+ save.title = 'This color is too low-contrast to use';
352
+ } else if (level === 'warn') {
353
+ badge.className = 'contrast-badge cb-warn';
354
+ badge.innerHTML = '<span class="cb-icon">⚠️</span><span>Readable, but low-contrast — viewers with vision ' +
355
+ 'issues may struggle to read your chat lines.</span>';
356
+ save.disabled = false;
357
+ save.title = '';
358
+ } else {
359
+ badge.className = 'contrast-badge cb-ok';
360
+ badge.innerHTML = '<span class="cb-icon">✅</span><span>Good contrast on the chat background.</span>';
361
+ save.disabled = false;
362
+ save.title = '';
363
+ }
364
+ }
365
+
296
366
  function editColor() {
297
367
  if (!accountState) return;
298
368
  const sym = accountState.currency_symbol || 'Z';
299
369
  const cur = (accountState.vanity && accountState.vanity.custom_color) || '';
300
370
  const cost = (accountState.vanity_costs && accountState.vanity_costs.custom_color) || 0;
301
371
  const seed = /^#[0-9A-Fa-f]{6}$/.test(cur) ? cur : '#6C5CE7';
372
+ const uname = (accountState.username) || 'You';
302
373
  showModal(`
303
374
  <h3>Chat color</h3>
304
375
  <p class="muted">Pick a color for your chat messages. Cost: ${fmt(cost, sym)} per update.</p>
376
+ <div class="color-preview">
377
+ <span class="cp-ts">[12:34:56]</span>
378
+ <span class="cp-name" id="cp-name">${escapeHtml(uname)}:</span>
379
+ <span class="cp-msg" id="cp-msg">This is how your chat messages will look.</span>
380
+ </div>
381
+ <div class="contrast-badge cb-ok" id="contrast-badge"></div>
305
382
  <div class="color-picker-row">
306
383
  <input type="color" id="color-input" value="${seed}">
307
384
  <input type="text" id="color-hex" class="color-hex" value="${escapeHtml(cur || seed)}" maxlength="7" placeholder="#RRGGBB">
@@ -312,14 +389,25 @@ function editColor() {
312
389
  </div>`);
313
390
  const picker = document.getElementById('color-input');
314
391
  const hex = document.getElementById('color-hex');
315
- picker.addEventListener('input', () => { hex.value = picker.value.toUpperCase(); });
392
+ const normalize = (v) => {
393
+ v = v.trim();
394
+ if (!v.startsWith('#')) v = '#' + v;
395
+ return v.toUpperCase();
396
+ };
397
+ picker.addEventListener('input', () => {
398
+ hex.value = picker.value.toUpperCase();
399
+ updateColorPreview(picker.value.toUpperCase());
400
+ });
316
401
  hex.addEventListener('input', () => {
317
402
  const val = hex.value.trim();
318
403
  if (/^#?[0-9A-Fa-f]{6}$/.test(val)) {
319
- picker.value = (val.startsWith('#') ? '' : '#') + val;
404
+ const norm = normalize(val);
405
+ picker.value = norm;
406
+ updateColorPreview(norm);
320
407
  }
321
408
  });
322
409
  document.getElementById('color-save').addEventListener('click', saveColor);
410
+ updateColorPreview(normalize(cur || seed));
323
411
  }
324
412
 
325
413
  async function saveColor() {
@@ -329,6 +417,12 @@ async function saveColor() {
329
417
  return;
330
418
  }
331
419
  if (!value.startsWith('#')) value = '#' + value;
420
+ value = value.toUpperCase();
421
+ // Block locally on reject (the server enforces this too).
422
+ if (classifyColor(value).level === 'reject') {
423
+ showToast('That color is too low-contrast to use on the chat background', 'error');
424
+ return;
425
+ }
332
426
  const btn = document.getElementById('color-save');
333
427
  btn.disabled = true;
334
428
  let resp;
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import logging
3
+ import uuid
3
4
  from fastapi import APIRouter, WebSocket, WebSocketDisconnect
4
5
  import jwt
5
6
 
@@ -57,3 +58,47 @@ async def websocket_endpoint(ws: WebSocket):
57
58
  logger.warning(f"WS error for {username}: {e}")
58
59
  finally:
59
60
  await manager.disconnect(username)
61
+
62
+
63
+ @router.websocket("/ws/race")
64
+ async def race_websocket_endpoint(ws: WebSocket):
65
+ """Public race-view WebSocket. No authentication — streams only race frames.
66
+
67
+ Anyone watching the channel can open the race view without logging into the
68
+ queue dashboard. Connections are anonymous (keyed by a random id) and only
69
+ ever receive race frames (no user/queue data), so this is safe to expose.
70
+ """
71
+ await ws.accept()
72
+ manager = ws.app.state.race_ws_manager
73
+ poller = getattr(ws.app.state, "race_poller", None)
74
+ conn_id = uuid.uuid4().hex
75
+
76
+ await manager.connect(conn_id, ws)
77
+
78
+ # Show a race that's already in progress to a late-joining spectator.
79
+ try:
80
+ if poller is not None and poller.last_frame is not None:
81
+ await ws.send_text(
82
+ json.dumps({"type": "race_frame", "data": poller.last_frame})
83
+ )
84
+ except Exception:
85
+ await manager.disconnect(conn_id)
86
+ return
87
+
88
+ # Keep the socket open; ignore inbound except ping/pong.
89
+ try:
90
+ while True:
91
+ text = await ws.receive_text()
92
+ try:
93
+ msg = json.loads(text)
94
+ except json.JSONDecodeError:
95
+ continue
96
+ if msg.get("type") == "ping":
97
+ await ws.send_text(json.dumps({"type": "pong"}))
98
+ except WebSocketDisconnect:
99
+ pass
100
+ except Exception as e:
101
+ logger.debug(f"Race WS error: {e}")
102
+ finally:
103
+ await manager.disconnect(conn_id)
104
+
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.23.1"
3
+ version = "0.25.0"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,102 @@
1
+ """RacePoller — adaptive race-frame polling + WebSocket broadcast.
2
+
3
+ Drives ``_poll_once()`` directly (the loop is just sleep + retry) to assert the
4
+ broadcast/cache/back-off behaviour without real timers or sockets.
5
+ """
6
+
7
+ from kryten_webqueue.queue.race_poller import RacePoller
8
+
9
+
10
+ class _FakeApiGate:
11
+ def __init__(self, results):
12
+ # results: list of dicts to return, or Exception instances to raise.
13
+ self._results = list(results)
14
+ self.calls = 0
15
+
16
+ async def get_race_state(self):
17
+ self.calls += 1
18
+ val = self._results[min(self.calls - 1, len(self._results) - 1)]
19
+ if isinstance(val, Exception):
20
+ raise val
21
+ return val
22
+
23
+
24
+ class _FakeWs:
25
+ def __init__(self):
26
+ self.messages = []
27
+
28
+ async def broadcast(self, message):
29
+ self.messages.append(message)
30
+
31
+
32
+ async def test_active_frame_broadcasts_and_caches():
33
+ ws = _FakeWs()
34
+ frame = {"phase": "racing", "tick": 1}
35
+ poller = RacePoller(
36
+ api_gate=_FakeApiGate([{"active": True, "frame": frame}]),
37
+ ws_manager=ws, active_interval=1.5, idle_interval=4.0,
38
+ )
39
+ delay = await poller._poll_once()
40
+ assert delay == 1.5
41
+ assert poller.last_frame == frame
42
+ assert ws.messages == [{"type": "race_frame", "data": frame}]
43
+
44
+
45
+ async def test_idle_without_prior_does_not_broadcast():
46
+ ws = _FakeWs()
47
+ poller = RacePoller(
48
+ api_gate=_FakeApiGate([{"active": False, "frame": None}]),
49
+ ws_manager=ws,
50
+ )
51
+ delay = await poller._poll_once()
52
+ assert delay == poller._idle_interval
53
+ assert ws.messages == []
54
+ assert poller.last_frame is None
55
+
56
+
57
+ async def test_clear_broadcast_once_after_active():
58
+ ws = _FakeWs()
59
+ poller = RacePoller(
60
+ api_gate=_FakeApiGate([
61
+ {"active": True, "frame": {"phase": "racing"}},
62
+ {"active": False, "frame": None},
63
+ {"active": False, "frame": None},
64
+ ]),
65
+ ws_manager=ws,
66
+ )
67
+ await poller._poll_once() # active
68
+ await poller._poll_once() # transitions to idle → one clear
69
+ await poller._poll_once() # still idle → no repeat
70
+ assert [m["type"] for m in ws.messages] == ["race_frame", "race_clear"]
71
+ assert poller.last_frame is None
72
+
73
+
74
+ async def test_active_false_with_stale_frame_is_idle():
75
+ """``active: true`` but a null frame is treated as no race (defensive)."""
76
+ ws = _FakeWs()
77
+ poller = RacePoller(
78
+ api_gate=_FakeApiGate([{"active": True, "frame": None}]),
79
+ ws_manager=ws,
80
+ )
81
+ delay = await poller._poll_once()
82
+ assert delay == poller._idle_interval
83
+ assert ws.messages == []
84
+ assert poller.last_frame is None
85
+
86
+
87
+ async def test_error_backs_off_without_clearing():
88
+ ws = _FakeWs()
89
+ poller = RacePoller(
90
+ api_gate=_FakeApiGate([
91
+ {"active": True, "frame": {"phase": "racing"}},
92
+ RuntimeError("nats hiccup"),
93
+ ]),
94
+ ws_manager=ws,
95
+ )
96
+ await poller._poll_once() # active → caches frame
97
+ delay = await poller._poll_once() # error
98
+ assert delay == poller._idle_interval
99
+ # No spurious clear; the cached frame is retained so a reconnecting
100
+ # spectator still sees the race in progress.
101
+ assert poller.last_frame == {"phase": "racing"}
102
+ assert [m["type"] for m in ws.messages] == ["race_frame"]