kryten-webqueue 0.24.0__tar.gz → 0.26.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.
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/CHANGELOG.md +17 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/api_gate/client.py +10 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/app.py +9 -0
- kryten_webqueue-0.26.0/kryten_webqueue/queue/race_poller.py +120 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/pages.py +7 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/base.html +1 -0
- kryten_webqueue-0.26.0/kryten_webqueue/templates/race.html +395 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/ws/handler.py +45 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/pyproject.toml +1 -1
- kryten_webqueue-0.26.0/tests/test_race_poller.py +163 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/.gitignore +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/README.md +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/config.example.json +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/catalog/db.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/tests/test_search_facets.py +0 -0
|
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
|
|
|
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
7
|
|
|
8
|
+
## [0.26.0] — 2026-06-22
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- **Race view is now smooth, with cars, drivers, and live commentary.** The economy now ships the whole race as a precomputed timeline (economy 0.13.0), so the `/race` view animates it client-side with `requestAnimationFrame` + interpolation — cars glide continuously and re-sync to the server clock instead of jumping between polls. Visual overhaul: each racer is a little CSS race **car** (cabin + wheels) that drives down a striped track from a colour start-cap to a checkered finish, with the **driver's name** (e.g. *Manuel Transmission*) under its colour, a live **position** column, and bet totals. A **commentary banner** above the field shows play-by-play (start, driver-named lead changes, close-finish, winner call) with the newest line replacing the last. Now shows all **6** cars.
|
|
13
|
+
- **Race WebSocket bandwidth cut.** The heavy position timeline is broadcast only once per race; subsequent frames carry just the server `elapsed` re-sync hint (the browser self-animates), so per-tick traffic stays tiny even with many spectators. Late-joiners still receive the full timeline on connect.
|
|
14
|
+
|
|
15
|
+
[0.26.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.26.0
|
|
16
|
+
|
|
17
|
+
## [0.25.0] — 2026-06-22
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **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.
|
|
22
|
+
|
|
23
|
+
[0.25.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.25.0
|
|
24
|
+
|
|
8
25
|
## [0.24.0] — 2026-06-21
|
|
9
26
|
|
|
10
27
|
### Added
|
|
@@ -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,120 @@
|
|
|
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
|
+
# race_id whose heavy timeline has already been broadcast; subsequent
|
|
35
|
+
# frames for the same race are sent "light" (timeline body stripped).
|
|
36
|
+
self._bcast_race_id: str | None = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def last_frame(self) -> dict | None:
|
|
40
|
+
"""The most recent live frame, or None when no race is active."""
|
|
41
|
+
return self._last_frame
|
|
42
|
+
|
|
43
|
+
async def start(self):
|
|
44
|
+
self._task = asyncio.create_task(self._loop())
|
|
45
|
+
logger.info(
|
|
46
|
+
"RacePoller started (active=%.1fs idle=%.1fs)",
|
|
47
|
+
self._active_interval, self._idle_interval,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def stop(self):
|
|
51
|
+
if self._task:
|
|
52
|
+
self._task.cancel()
|
|
53
|
+
try:
|
|
54
|
+
await self._task
|
|
55
|
+
except asyncio.CancelledError:
|
|
56
|
+
pass
|
|
57
|
+
logger.info("RacePoller stopped")
|
|
58
|
+
|
|
59
|
+
async def _poll_once(self) -> float:
|
|
60
|
+
"""Run one poll; broadcast as needed. Returns the next sleep interval."""
|
|
61
|
+
try:
|
|
62
|
+
result = await self._api_gate.get_race_state()
|
|
63
|
+
except asyncio.CancelledError:
|
|
64
|
+
raise
|
|
65
|
+
except Exception as e:
|
|
66
|
+
logger.debug("Race poll error: %s", e)
|
|
67
|
+
# Treat an error like "idle" but don't spuriously clear an active
|
|
68
|
+
# view — just back off and retry.
|
|
69
|
+
return self._idle_interval
|
|
70
|
+
|
|
71
|
+
active = bool(result.get("active")) and result.get("frame") is not None
|
|
72
|
+
if active:
|
|
73
|
+
frame = result["frame"]
|
|
74
|
+
self._last_frame = frame
|
|
75
|
+
await self._ws_manager.broadcast(
|
|
76
|
+
{"type": "race_frame", "data": self._strip_repeat_timeline(frame)}
|
|
77
|
+
)
|
|
78
|
+
return self._active_interval
|
|
79
|
+
|
|
80
|
+
# Not active. If we were showing a race, tell spectators to clear once.
|
|
81
|
+
if self._last_frame is not None:
|
|
82
|
+
self._last_frame = None
|
|
83
|
+
self._bcast_race_id = None
|
|
84
|
+
await self._ws_manager.broadcast({"type": "race_clear"})
|
|
85
|
+
return self._idle_interval
|
|
86
|
+
|
|
87
|
+
def _strip_repeat_timeline(self, frame: dict) -> dict:
|
|
88
|
+
"""Drop the heavy timeline body after its first broadcast per race.
|
|
89
|
+
|
|
90
|
+
The browser caches the timeline and self-animates, so resending ~100
|
|
91
|
+
position frames every poll is pure waste (especially with many
|
|
92
|
+
spectators). The first racing frame for a race carries the full
|
|
93
|
+
timeline; later frames keep only ``elapsed`` (a tiny re-sync hint).
|
|
94
|
+
Betting/finished frames and new-race frames always pass through whole.
|
|
95
|
+
"""
|
|
96
|
+
rid = frame.get("race_id")
|
|
97
|
+
tl = frame.get("timeline")
|
|
98
|
+
if tl is None:
|
|
99
|
+
# No timeline (betting/finished) — pass through, reset on new race.
|
|
100
|
+
if frame.get("phase") != "racing":
|
|
101
|
+
self._bcast_race_id = None if frame.get("phase") == "finished" else self._bcast_race_id
|
|
102
|
+
return frame
|
|
103
|
+
if rid == self._bcast_race_id:
|
|
104
|
+
light = dict(frame)
|
|
105
|
+
light["timeline"] = {"elapsed": tl.get("elapsed", 0)}
|
|
106
|
+
return light
|
|
107
|
+
# First racing frame for this race — send full timeline, then mark sent.
|
|
108
|
+
self._bcast_race_id = rid
|
|
109
|
+
return frame
|
|
110
|
+
|
|
111
|
+
async def _loop(self):
|
|
112
|
+
while True:
|
|
113
|
+
try:
|
|
114
|
+
delay = await self._poll_once()
|
|
115
|
+
except asyncio.CancelledError:
|
|
116
|
+
raise
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.warning("RacePoller loop error: %s", e)
|
|
119
|
+
delay = self._idle_interval
|
|
120
|
+
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,
|
|
@@ -0,0 +1,395 @@
|
|
|
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: 960px; 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.9; }
|
|
11
|
+
.race-status .pill { padding: 0.18rem 0.7rem; border-radius: 999px; font-size: 0.82rem; }
|
|
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
|
+
/* Commentary feed — newest line, fades on change. */
|
|
18
|
+
.race-commentary {
|
|
19
|
+
margin: 0.9rem 0 0.4rem; min-height: 1.9rem; display: flex; align-items: center;
|
|
20
|
+
gap: 0.5rem; padding: 0.5rem 0.85rem; border-radius: 10px;
|
|
21
|
+
background: linear-gradient(90deg, #161b22, #0d1117);
|
|
22
|
+
border: 1px solid #232a33; font-size: 1.02rem; font-weight: 500;
|
|
23
|
+
}
|
|
24
|
+
.race-commentary .mic { opacity: 0.7; }
|
|
25
|
+
.race-commentary .txt { transition: opacity 0.25s ease; }
|
|
26
|
+
.race-commentary .txt.swap { opacity: 0; }
|
|
27
|
+
|
|
28
|
+
.race-track { margin: 0.6rem 0 1rem; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
29
|
+
.lane {
|
|
30
|
+
position: relative; display: grid; grid-template-columns: 2.2rem 12.5rem 1fr auto;
|
|
31
|
+
align-items: center; gap: 0.7rem; padding: 0.4rem 0.55rem; border-radius: 11px;
|
|
32
|
+
background: #11141a; border: 1px solid #232a33; transition: border-color 0.3s, box-shadow 0.3s;
|
|
33
|
+
}
|
|
34
|
+
.lane.win { border-color: #f0c674; box-shadow: 0 0 0 1px #f0c67455, 0 0 22px #f0c67433; }
|
|
35
|
+
.lane-pos {
|
|
36
|
+
font-weight: 800; text-align: center; font-variant-numeric: tabular-nums;
|
|
37
|
+
color: #c9d1d9; font-size: 0.95rem;
|
|
38
|
+
}
|
|
39
|
+
.lane-pos.p1 { color: #f0c674; }
|
|
40
|
+
.lane-label { display: flex; flex-direction: column; line-height: 1.15; min-width: 0; }
|
|
41
|
+
.lane-color { display: flex; align-items: center; gap: 0.4rem; font-weight: 700; font-size: 0.92rem; }
|
|
42
|
+
.lane-swatch { width: 0.8rem; height: 0.8rem; border-radius: 50%; flex: 0 0 auto; box-shadow: 0 0 0 1px #0006 inset; }
|
|
43
|
+
.lane-driver { font-size: 0.78rem; opacity: 0.72; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
44
|
+
.lane-meta { text-align: right; font-variant-numeric: tabular-nums; }
|
|
45
|
+
.lane-odds { font-weight: 700; font-size: 0.9rem; }
|
|
46
|
+
.lane-amt { font-size: 0.74rem; opacity: 0.72; }
|
|
47
|
+
|
|
48
|
+
/* Track rail with start/finish color markers and a moving car. */
|
|
49
|
+
.lane-rail {
|
|
50
|
+
position: relative; height: 30px; border-radius: 8px;
|
|
51
|
+
background: repeating-linear-gradient(90deg, #0c0e12 0 18px, #0e1117 18px 36px);
|
|
52
|
+
border: 1px solid #1c222b; overflow: hidden;
|
|
53
|
+
}
|
|
54
|
+
.rail-cap { position: absolute; top: 0; bottom: 0; width: 5px; opacity: 0.85; }
|
|
55
|
+
.rail-cap.start { left: 0; border-radius: 8px 0 0 8px; }
|
|
56
|
+
.rail-cap.finish {
|
|
57
|
+
right: 0; border-radius: 0 8px 8px 0;
|
|
58
|
+
background-image: repeating-linear-gradient(45deg, #fff 0 4px, #000 4px 8px) !important;
|
|
59
|
+
opacity: 0.55; width: 8px;
|
|
60
|
+
}
|
|
61
|
+
.car {
|
|
62
|
+
position: absolute; top: 50%; left: 0; transform: translate(-50%, -50%);
|
|
63
|
+
width: 30px; height: 15px; border-radius: 5px 7px 4px 4px;
|
|
64
|
+
box-shadow: 0 1px 3px #000a, inset 0 -3px 4px #0004; will-change: left;
|
|
65
|
+
}
|
|
66
|
+
.car::before { /* cabin */
|
|
67
|
+
content: ''; position: absolute; left: 6px; top: -4px; width: 12px; height: 7px;
|
|
68
|
+
border-radius: 4px 4px 0 0; background: #ffffffcc;
|
|
69
|
+
}
|
|
70
|
+
.car::after { /* wheels */
|
|
71
|
+
content: ''; position: absolute; left: 3px; right: 3px; bottom: -3px; height: 5px;
|
|
72
|
+
background:
|
|
73
|
+
radial-gradient(circle at 5px 50%, #111 0 3px, transparent 3px),
|
|
74
|
+
radial-gradient(circle at calc(100% - 5px) 50%, #111 0 3px, transparent 3px);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.race-side { display: flex; gap: 1.6rem; flex-wrap: wrap; font-variant-numeric: tabular-nums; opacity: 0.92; }
|
|
78
|
+
.race-side .stat b { font-size: 1.12rem; }
|
|
79
|
+
.race-winner { margin: 1rem 0; padding: 0.8rem 1.05rem; border-radius: 11px;
|
|
80
|
+
background: #d2992218; border: 1px solid #f0c67455; }
|
|
81
|
+
.race-winner h2 { margin: 0 0 0.4rem; font-family: 'Sora', sans-serif; }
|
|
82
|
+
.race-payouts { margin: 0.3rem 0 0; padding-left: 1.15rem; }
|
|
83
|
+
.race-payouts li { margin: 0.1rem 0; }
|
|
84
|
+
.race-idle { text-align: center; opacity: 0.7; padding: 3rem 1rem; }
|
|
85
|
+
|
|
86
|
+
@media (max-width: 640px) {
|
|
87
|
+
.lane { grid-template-columns: 1.7rem 1fr auto; }
|
|
88
|
+
.lane-rail { grid-column: 1 / -1; order: 5; }
|
|
89
|
+
.lane-driver { white-space: normal; }
|
|
90
|
+
}
|
|
91
|
+
</style>
|
|
92
|
+
{% endblock %}
|
|
93
|
+
|
|
94
|
+
{% block content %}
|
|
95
|
+
<div class="race-wrap">
|
|
96
|
+
<div class="race-head">
|
|
97
|
+
<h1>🏁 Live Race</h1>
|
|
98
|
+
<div class="race-status" id="race-status"><span class="pill pill-idle">Connecting…</span></div>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div class="race-commentary" id="race-commentary" hidden>
|
|
102
|
+
<span class="mic">📣</span><span class="txt" id="commentary-txt"></span>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div id="race-body">
|
|
106
|
+
<div class="race-idle" id="race-idle">Waiting for the next race…</div>
|
|
107
|
+
<div class="race-track" id="race-track" hidden></div>
|
|
108
|
+
<div class="race-side" id="race-side" hidden></div>
|
|
109
|
+
<div class="race-winner" id="race-winner" hidden></div>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
{% endblock %}
|
|
113
|
+
|
|
114
|
+
{% block scripts %}
|
|
115
|
+
<script>
|
|
116
|
+
(function () {
|
|
117
|
+
const COLORS = {
|
|
118
|
+
Blue: '#3b82f6', Red: '#ef4444', Green: '#22c55e',
|
|
119
|
+
Yellow: '#eab308', Purple: '#a855f7', Orange: '#f97316',
|
|
120
|
+
};
|
|
121
|
+
const colorOf = (c) => COLORS[c] || '#9aa4af';
|
|
122
|
+
|
|
123
|
+
const statusEl = document.getElementById('race-status');
|
|
124
|
+
const idleEl = document.getElementById('race-idle');
|
|
125
|
+
const trackEl = document.getElementById('race-track');
|
|
126
|
+
const sideEl = document.getElementById('race-side');
|
|
127
|
+
const winnerEl = document.getElementById('race-winner');
|
|
128
|
+
const commEl = document.getElementById('race-commentary');
|
|
129
|
+
const commTxt = document.getElementById('commentary-txt');
|
|
130
|
+
|
|
131
|
+
const lanes = new Map(); // color -> { row, car, pos, amt }
|
|
132
|
+
let laneSig = '';
|
|
133
|
+
let raceId = null;
|
|
134
|
+
|
|
135
|
+
// playback state
|
|
136
|
+
let timeline = null; // {frame_dt, duration, frames, commentary, elapsed}
|
|
137
|
+
let currentColors = [];
|
|
138
|
+
let playbackStart = 0; // performance.now() baseline
|
|
139
|
+
let rafId = 0;
|
|
140
|
+
let commentaryShown = -1;
|
|
141
|
+
|
|
142
|
+
function esc(s) {
|
|
143
|
+
return String(s).replace(/[&<>"']/g, c => (
|
|
144
|
+
{ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]
|
|
145
|
+
));
|
|
146
|
+
}
|
|
147
|
+
function setStatus(kind, text) {
|
|
148
|
+
statusEl.innerHTML = '<span class="pill pill-' + kind + '">' + esc(text) + '</span>';
|
|
149
|
+
}
|
|
150
|
+
function setCommentary(text) {
|
|
151
|
+
if (!text) return;
|
|
152
|
+
commEl.hidden = false;
|
|
153
|
+
if (commTxt.textContent === text) return;
|
|
154
|
+
commTxt.classList.add('swap');
|
|
155
|
+
setTimeout(() => { commTxt.textContent = text; commTxt.classList.remove('swap'); }, 130);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildLanes(racers) {
|
|
159
|
+
trackEl.innerHTML = '';
|
|
160
|
+
lanes.clear();
|
|
161
|
+
for (const r of racers) {
|
|
162
|
+
const col = colorOf(r.color);
|
|
163
|
+
const row = document.createElement('div');
|
|
164
|
+
row.className = 'lane';
|
|
165
|
+
row.innerHTML =
|
|
166
|
+
'<div class="lane-pos"></div>' +
|
|
167
|
+
'<div class="lane-label">' +
|
|
168
|
+
'<span class="lane-color"><span class="lane-swatch" style="background:' + col + '"></span>' + esc(r.color) + '</span>' +
|
|
169
|
+
'<span class="lane-driver">' + esc(r.name || '') + '</span>' +
|
|
170
|
+
'</div>' +
|
|
171
|
+
'<div class="lane-rail">' +
|
|
172
|
+
'<span class="rail-cap start" style="background:' + col + '"></span>' +
|
|
173
|
+
'<span class="rail-cap finish"></span>' +
|
|
174
|
+
'<span class="car" style="background:' + col + '"></span>' +
|
|
175
|
+
'</div>' +
|
|
176
|
+
'<div class="lane-meta">' +
|
|
177
|
+
'<div class="lane-odds">' + esc(r.odds || '') + '</div>' +
|
|
178
|
+
'<div class="lane-amt"></div>' +
|
|
179
|
+
'</div>';
|
|
180
|
+
trackEl.appendChild(row);
|
|
181
|
+
lanes.set(r.color, {
|
|
182
|
+
row,
|
|
183
|
+
car: row.querySelector('.car'),
|
|
184
|
+
pos: row.querySelector('.lane-pos'),
|
|
185
|
+
amt: row.querySelector('.lane-amt'),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Place a car along its rail (pct 0..100 → left 3%..97% so it stays on track).
|
|
191
|
+
function placeCar(color, pct) {
|
|
192
|
+
const lane = lanes.get(color);
|
|
193
|
+
if (!lane) return;
|
|
194
|
+
const left = 3 + Math.max(0, Math.min(100, pct)) * 0.94;
|
|
195
|
+
lane.car.style.left = left + '%';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function updateRanking(colors, values) {
|
|
199
|
+
const order = colors
|
|
200
|
+
.map((c, i) => [c, values[i]])
|
|
201
|
+
.sort((a, b) => b[1] - a[1]);
|
|
202
|
+
order.forEach(([c], idx) => {
|
|
203
|
+
const lane = lanes.get(c);
|
|
204
|
+
if (!lane) return;
|
|
205
|
+
lane.pos.textContent = '#' + (idx + 1);
|
|
206
|
+
lane.pos.classList.toggle('p1', idx === 0);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function applyBets(frame) {
|
|
211
|
+
const bets = frame.bets_by_color || {};
|
|
212
|
+
for (const [color, lane] of lanes) {
|
|
213
|
+
const b = bets[color];
|
|
214
|
+
lane.amt.textContent = b ? ('💰 ' + b.amount.toLocaleString()) : '';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function sideStats(frame) {
|
|
219
|
+
const sym = frame.symbol || '';
|
|
220
|
+
sideEl.hidden = false;
|
|
221
|
+
sideEl.innerHTML =
|
|
222
|
+
'<div class="stat">Pool <b>' + (frame.pool || 0).toLocaleString() + ' ' + esc(sym) + '</b></div>' +
|
|
223
|
+
'<div class="stat">Bettors <b>' + (frame.bettor_count || 0) + '</b></div>' +
|
|
224
|
+
(frame.phase === 'betting'
|
|
225
|
+
? '<div class="stat">Min bet <b>' + (frame.min_bet || 0).toLocaleString() + ' ' + esc(sym) + '</b></div>'
|
|
226
|
+
: '');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Timeline playback (smooth, rAF + interpolation) ────────
|
|
230
|
+
function posAt(t) {
|
|
231
|
+
const { frame_dt, frames } = timeline;
|
|
232
|
+
const pos = t / frame_dt;
|
|
233
|
+
let i = Math.floor(pos);
|
|
234
|
+
if (i >= frames.length - 1) return frames[frames.length - 1].slice();
|
|
235
|
+
const frac = pos - i;
|
|
236
|
+
const a = frames[i], b = frames[i + 1];
|
|
237
|
+
return a.map((v, j) => v + (b[j] - v) * frac);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function showCommentaryUpTo(t) {
|
|
241
|
+
if (!timeline.commentary) return;
|
|
242
|
+
let idx = commentaryShown;
|
|
243
|
+
for (let k = commentaryShown + 1; k < timeline.commentary.length; k++) {
|
|
244
|
+
if (timeline.commentary[k].t <= t) idx = k; else break;
|
|
245
|
+
}
|
|
246
|
+
if (idx !== commentaryShown) {
|
|
247
|
+
commentaryShown = idx;
|
|
248
|
+
setCommentary(timeline.commentary[idx].text);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function frameTick(now) {
|
|
253
|
+
if (!timeline) return;
|
|
254
|
+
let t = (now - playbackStart) / 1000;
|
|
255
|
+
if (t < 0) t = 0;
|
|
256
|
+
const done = t >= timeline.duration;
|
|
257
|
+
if (done) t = timeline.duration;
|
|
258
|
+
|
|
259
|
+
const row = posAt(t);
|
|
260
|
+
currentColors.forEach((c, j) => placeCar(c, row[j]));
|
|
261
|
+
updateRanking(currentColors, row);
|
|
262
|
+
showCommentaryUpTo(t);
|
|
263
|
+
|
|
264
|
+
if (!done) rafId = requestAnimationFrame(frameTick);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function startPlayback(frame) {
|
|
268
|
+
timeline = frame.timeline;
|
|
269
|
+
currentColors = frame.racers.map(r => r.color);
|
|
270
|
+
commentaryShown = -1;
|
|
271
|
+
const elapsed = Math.max(0, timeline.elapsed || 0);
|
|
272
|
+
playbackStart = performance.now() - elapsed * 1000;
|
|
273
|
+
cancelAnimationFrame(rafId);
|
|
274
|
+
rafId = requestAnimationFrame(frameTick);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function resync(frame) {
|
|
278
|
+
// Gently correct drift (e.g. after a backgrounded tab) without jumping.
|
|
279
|
+
const elapsed = Math.max(0, frame.timeline.elapsed || 0);
|
|
280
|
+
const localT = (performance.now() - playbackStart) / 1000;
|
|
281
|
+
if (Math.abs(localT - elapsed) > 1.2) {
|
|
282
|
+
playbackStart = performance.now() - elapsed * 1000;
|
|
283
|
+
}
|
|
284
|
+
timeline.commentary = frame.timeline.commentary || timeline.commentary;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Frame router ───────────────────────────────────────────
|
|
288
|
+
function render(frame) {
|
|
289
|
+
idleEl.hidden = true;
|
|
290
|
+
trackEl.hidden = false;
|
|
291
|
+
|
|
292
|
+
const sig = frame.racers.map(r => r.color).join('|');
|
|
293
|
+
const isNewRace = frame.race_id !== raceId || sig !== laneSig;
|
|
294
|
+
if (isNewRace) {
|
|
295
|
+
buildLanes(frame.racers);
|
|
296
|
+
laneSig = sig;
|
|
297
|
+
raceId = frame.race_id;
|
|
298
|
+
timeline = null;
|
|
299
|
+
cancelAnimationFrame(rafId);
|
|
300
|
+
}
|
|
301
|
+
applyBets(frame);
|
|
302
|
+
sideStats(frame);
|
|
303
|
+
|
|
304
|
+
if (frame.phase === 'betting') {
|
|
305
|
+
cancelAnimationFrame(rafId); timeline = null;
|
|
306
|
+
frame.racers.forEach(r => placeCar(r.color, 0));
|
|
307
|
+
updateRanking(frame.racers.map(r => r.color), frame.racers.map(() => 0));
|
|
308
|
+
winnerEl.hidden = true;
|
|
309
|
+
setStatus('betting', 'Betting open — ' + (frame.betting_closes_in || 0) + 's left');
|
|
310
|
+
setCommentary('Place your bets! ' + frame.racers.length + ' cars on the grid.');
|
|
311
|
+
} else if (frame.phase === 'racing') {
|
|
312
|
+
winnerEl.hidden = true;
|
|
313
|
+
setStatus('racing', 'They\u2019re off!');
|
|
314
|
+
if (frame.timeline) {
|
|
315
|
+
const hasFrames = Array.isArray(frame.timeline.frames) && frame.timeline.frames.length;
|
|
316
|
+
if (!timeline && hasFrames) startPlayback(frame);
|
|
317
|
+
else if (timeline) resync(frame);
|
|
318
|
+
// else: a light sync arrived before the full timeline — wait for it.
|
|
319
|
+
}
|
|
320
|
+
} else if (frame.phase === 'finished') {
|
|
321
|
+
cancelAnimationFrame(rafId); timeline = null;
|
|
322
|
+
showFinished(frame);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function showFinished(frame) {
|
|
327
|
+
setStatus('finished', 'Finished');
|
|
328
|
+
const winnerColor = frame.winner && frame.winner.color;
|
|
329
|
+
frame.racers.forEach(r => placeCar(r.color, r.pct || 0));
|
|
330
|
+
updateRanking(frame.racers.map(r => r.color), frame.racers.map(r => r.pct || 0));
|
|
331
|
+
for (const [c, lane] of lanes) lane.row.classList.toggle('win', c === winnerColor);
|
|
332
|
+
|
|
333
|
+
const sym = frame.symbol || '';
|
|
334
|
+
if (frame.winner) {
|
|
335
|
+
const w = frame.winner;
|
|
336
|
+
const driver = w.name ? (' — ' + esc(w.name)) : '';
|
|
337
|
+
const payouts = (frame.payouts || []).slice(0, 6)
|
|
338
|
+
.map(p => '<li>' + esc(p.username) + ' <b>+' + (p.net || 0).toLocaleString() + ' ' + esc(sym) + '</b></li>')
|
|
339
|
+
.join('');
|
|
340
|
+
winnerEl.hidden = false;
|
|
341
|
+
winnerEl.innerHTML =
|
|
342
|
+
'<h2><span class="lane-swatch" style="display:inline-block;vertical-align:middle;background:' + colorOf(w.color) + '"></span> ' +
|
|
343
|
+
esc(w.color) + ' wins!' + driver + '</h2>' +
|
|
344
|
+
(payouts ? '<ul class="race-payouts">' + payouts + '</ul>' : '<div>No winning bets this race.</div>');
|
|
345
|
+
setCommentary('🏆 ' + esc(w.color) + (w.name ? ' (' + esc(w.name) + ')' : '') + ' takes the checkered flag!');
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function showIdle() {
|
|
350
|
+
laneSig = ''; raceId = null; timeline = null;
|
|
351
|
+
cancelAnimationFrame(rafId);
|
|
352
|
+
lanes.clear();
|
|
353
|
+
trackEl.hidden = true; sideEl.hidden = true; winnerEl.hidden = true;
|
|
354
|
+
commEl.hidden = true;
|
|
355
|
+
idleEl.hidden = false;
|
|
356
|
+
setStatus('idle', 'No race running');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── WebSocket with auto-reconnect ──────────────────────────
|
|
360
|
+
let ws = null, retry = 0, pingTimer = null;
|
|
361
|
+
function connect() {
|
|
362
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
363
|
+
ws = new WebSocket(proto + '://' + location.host + '/ws/race');
|
|
364
|
+
ws.onopen = () => {
|
|
365
|
+
retry = 0; setStatus('idle', 'Connected');
|
|
366
|
+
clearInterval(pingTimer);
|
|
367
|
+
pingTimer = setInterval(() => { try { ws.send(JSON.stringify({ type: 'ping' })); } catch (e) {} }, 25000);
|
|
368
|
+
};
|
|
369
|
+
ws.onmessage = (ev) => {
|
|
370
|
+
let m; try { m = JSON.parse(ev.data); } catch (e) { return; }
|
|
371
|
+
if (m.type === 'race_frame') render(m.data);
|
|
372
|
+
else if (m.type === 'race_clear') showIdle();
|
|
373
|
+
};
|
|
374
|
+
ws.onclose = () => {
|
|
375
|
+
clearInterval(pingTimer);
|
|
376
|
+
setStatus('idle', 'Reconnecting…');
|
|
377
|
+
retry = Math.min(retry + 1, 6);
|
|
378
|
+
setTimeout(connect, 500 * retry);
|
|
379
|
+
};
|
|
380
|
+
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Re-sync playback when returning to a backgrounded tab.
|
|
384
|
+
document.addEventListener('visibilitychange', () => {
|
|
385
|
+
if (!document.hidden && timeline) {
|
|
386
|
+
cancelAnimationFrame(rafId);
|
|
387
|
+
rafId = requestAnimationFrame(frameTick);
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
showIdle();
|
|
392
|
+
connect();
|
|
393
|
+
})();
|
|
394
|
+
</script>
|
|
395
|
+
{% endblock %}
|
|
@@ -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
|
+
|
|
@@ -0,0 +1,163 @@
|
|
|
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"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _racing_frame(race_id, elapsed, n_frames=3):
|
|
106
|
+
return {
|
|
107
|
+
"active": True,
|
|
108
|
+
"frame": {
|
|
109
|
+
"race_id": race_id,
|
|
110
|
+
"phase": "racing",
|
|
111
|
+
"timeline": {
|
|
112
|
+
"frame_dt": 0.3,
|
|
113
|
+
"duration": 1.0,
|
|
114
|
+
"elapsed": elapsed,
|
|
115
|
+
"frames": [[0.0]] * n_frames,
|
|
116
|
+
"commentary": [{"t": 0.0, "text": "go"}],
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def test_full_timeline_sent_once_then_stripped():
|
|
123
|
+
"""First racing frame carries the full timeline; later frames for the same
|
|
124
|
+
race carry only ``elapsed`` (the browser self-animates)."""
|
|
125
|
+
ws = _FakeWs()
|
|
126
|
+
poller = RacePoller(
|
|
127
|
+
api_gate=_FakeApiGate([
|
|
128
|
+
_racing_frame("race-A", 0.0),
|
|
129
|
+
_racing_frame("race-A", 0.3),
|
|
130
|
+
_racing_frame("race-A", 0.6),
|
|
131
|
+
]),
|
|
132
|
+
ws_manager=ws,
|
|
133
|
+
)
|
|
134
|
+
await poller._poll_once()
|
|
135
|
+
await poller._poll_once()
|
|
136
|
+
await poller._poll_once()
|
|
137
|
+
|
|
138
|
+
first = ws.messages[0]["data"]["timeline"]
|
|
139
|
+
assert "frames" in first and len(first["frames"]) == 3 # full
|
|
140
|
+
|
|
141
|
+
for later in ws.messages[1:]:
|
|
142
|
+
tl = later["data"]["timeline"]
|
|
143
|
+
assert tl == {"elapsed": tl["elapsed"]} # light: elapsed only
|
|
144
|
+
assert [m["data"]["timeline"]["elapsed"] for m in ws.messages] == [0.0, 0.3, 0.6]
|
|
145
|
+
# last_frame retains the FULL frame for late-joiner bootstrap.
|
|
146
|
+
assert "frames" in poller.last_frame["timeline"]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def test_new_race_resends_full_timeline():
|
|
150
|
+
"""A different race_id resets the strip state — full timeline again."""
|
|
151
|
+
ws = _FakeWs()
|
|
152
|
+
poller = RacePoller(
|
|
153
|
+
api_gate=_FakeApiGate([
|
|
154
|
+
_racing_frame("race-A", 0.0),
|
|
155
|
+
_racing_frame("race-B", 0.0),
|
|
156
|
+
]),
|
|
157
|
+
ws_manager=ws,
|
|
158
|
+
)
|
|
159
|
+
await poller._poll_once()
|
|
160
|
+
await poller._poll_once()
|
|
161
|
+
assert "frames" in ws.messages[0]["data"]["timeline"]
|
|
162
|
+
assert "frames" in ws.messages[1]["data"]["timeline"]
|
|
163
|
+
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/_common.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/ytpipe/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/integrations/ytpipe/downloader.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/index.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/promos.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/queue/index.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.24.0 → kryten_webqueue-0.26.0}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|