kryten-robot 0.6.9__py3-none-any.whl

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.
@@ -0,0 +1,314 @@
1
+ """State Updater - Subscribe to CyTube events and update StateManager.
2
+
3
+ This module bridges the EventPublisher and StateManager by subscribing to
4
+ relevant CyTube events and updating the state KV stores accordingly.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ from .nats_client import NatsClient
11
+ from .state_manager import StateManager
12
+ from .subject_builder import build_subject
13
+
14
+
15
+ class StateUpdater:
16
+ """Subscribe to CyTube events and update state KV stores.
17
+
18
+ Listens for events like playlist, userlist, emotes, queue, delete, etc.
19
+ and calls appropriate StateManager methods to keep KV stores synchronized.
20
+
21
+ Attributes:
22
+ nats_client: NATS client for subscriptions.
23
+ state_manager: StateManager to update.
24
+ channel: CyTube channel name.
25
+ domain: CyTube domain.
26
+ logger: Logger instance.
27
+
28
+ Examples:
29
+ >>> updater = StateUpdater(nats_client, state_manager, "mychannel", "cytu.be", logger)
30
+ >>> await updater.start()
31
+ >>> # Receives events and updates state automatically
32
+ >>> await updater.stop()
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ nats_client: NatsClient,
38
+ state_manager: StateManager,
39
+ channel: str,
40
+ domain: str,
41
+ logger: logging.Logger,
42
+ ):
43
+ """Initialize state updater.
44
+
45
+ Args:
46
+ nats_client: NATS client instance.
47
+ state_manager: StateManager to update.
48
+ channel: CyTube channel name.
49
+ domain: CyTube domain.
50
+ logger: Logger for structured output.
51
+ """
52
+ self._nats = nats_client
53
+ self._state = state_manager
54
+ self._channel = channel
55
+ self._domain = domain
56
+ self._logger = logger
57
+ self._running = False
58
+ self._subscriptions: list[Any] = []
59
+
60
+ @property
61
+ def is_running(self) -> bool:
62
+ """Check if updater is running.
63
+
64
+ Returns:
65
+ True if started and processing events, False otherwise.
66
+ """
67
+ return self._running
68
+
69
+ async def start(self) -> None:
70
+ """Start state updater and subscribe to events.
71
+
72
+ Subscribes to relevant CyTube events for the configured channel/domain.
73
+
74
+ Raises:
75
+ RuntimeError: If NATS is not connected or StateManager not started.
76
+ """
77
+ if self._running:
78
+ self._logger.debug("State updater already running")
79
+ return
80
+
81
+ if not self._nats.is_connected:
82
+ raise RuntimeError("NATS client not connected")
83
+
84
+ if not self._state.is_running:
85
+ raise RuntimeError("StateManager not started")
86
+
87
+ try:
88
+ self._logger.info(f"Starting state updater for {self._domain}/{self._channel}")
89
+
90
+ # Subscribe to playlist events
91
+ subject = build_subject(self._domain, self._channel, "playlist")
92
+ sub = await self._nats._nc.subscribe(subject, cb=self._handle_playlist)
93
+ self._subscriptions.append(sub)
94
+ self._logger.debug(f"Subscribed to {subject}")
95
+
96
+ subject = build_subject(self._domain, self._channel, "queue")
97
+ sub = await self._nats._nc.subscribe(subject, cb=self._handle_queue)
98
+ self._subscriptions.append(sub)
99
+ self._logger.debug(f"Subscribed to {subject}")
100
+
101
+ subject = build_subject(self._domain, self._channel, "delete")
102
+ sub = await self._nats._nc.subscribe(subject, cb=self._handle_delete)
103
+ self._subscriptions.append(sub)
104
+ self._logger.debug(f"Subscribed to {subject}")
105
+
106
+ subject = build_subject(self._domain, self._channel, "moveMedia")
107
+ sub = await self._nats._nc.subscribe(subject, cb=self._handle_move_media)
108
+ self._subscriptions.append(sub)
109
+ self._logger.debug(f"Subscribed to {subject}")
110
+
111
+ # Subscribe to userlist events
112
+ subject = build_subject(self._domain, self._channel, "userlist")
113
+ sub = await self._nats._nc.subscribe(subject, cb=self._handle_userlist)
114
+ self._subscriptions.append(sub)
115
+ self._logger.debug(f"Subscribed to {subject}")
116
+
117
+ subject = build_subject(self._domain, self._channel, "addUser")
118
+ sub = await self._nats._nc.subscribe(subject, cb=self._handle_add_user)
119
+ self._subscriptions.append(sub)
120
+ self._logger.debug(f"Subscribed to {subject}")
121
+
122
+ subject = build_subject(self._domain, self._channel, "userLeave")
123
+ sub = await self._nats._nc.subscribe(subject, cb=self._handle_user_leave)
124
+ self._subscriptions.append(sub)
125
+ self._logger.debug(f"Subscribed to {subject}")
126
+
127
+ # Subscribe to emotes event
128
+ subject = build_subject(self._domain, self._channel, "emoteList")
129
+ sub = await self._nats._nc.subscribe(subject, cb=self._handle_emote_list)
130
+ self._subscriptions.append(sub)
131
+ self._logger.debug(f"Subscribed to {subject}")
132
+
133
+ self._running = True
134
+ self._logger.info("State updater started successfully")
135
+
136
+ except Exception as e:
137
+ self._logger.error(f"Failed to start state updater: {e}", exc_info=True)
138
+ raise
139
+
140
+ async def stop(self) -> None:
141
+ """Stop state updater and unsubscribe from events.
142
+
143
+ Gracefully unsubscribes from all event subscriptions.
144
+ Safe to call multiple times.
145
+ """
146
+ if not self._running:
147
+ return
148
+
149
+ try:
150
+ self._logger.info("Stopping state updater")
151
+
152
+ # Unsubscribe from all subscriptions
153
+ for sub in self._subscriptions:
154
+ try:
155
+ await sub.unsubscribe()
156
+ except Exception as e:
157
+ self._logger.warning(f"Error unsubscribing: {e}")
158
+
159
+ self._subscriptions.clear()
160
+ self._running = False
161
+
162
+ self._logger.info("State updater stopped")
163
+
164
+ except Exception as e:
165
+ self._logger.error(f"Error stopping state updater: {e}", exc_info=True)
166
+
167
+ # ========================================================================
168
+ # Event Handlers
169
+ # ========================================================================
170
+
171
+ async def _handle_playlist(self, msg) -> None:
172
+ """Handle 'playlist' event (initial playlist load).
173
+
174
+ Args:
175
+ msg: NATS message with playlist data.
176
+ """
177
+ try:
178
+ import json
179
+ data = json.loads(msg.data.decode())
180
+
181
+ # Extract playlist items from event payload
182
+ playlist = data.get("payload", [])
183
+
184
+ await self._state.set_playlist(playlist)
185
+
186
+ except Exception as e:
187
+ self._logger.error(f"Error handling playlist event: {e}", exc_info=True)
188
+
189
+ async def _handle_queue(self, msg) -> None:
190
+ """Handle 'queue' event (video added to playlist).
191
+
192
+ Args:
193
+ msg: NATS message with queue data.
194
+ """
195
+ try:
196
+ import json
197
+ data = json.loads(msg.data.decode())
198
+ payload = data.get("payload", {})
199
+
200
+ item = payload.get("item", {})
201
+ after = payload.get("after")
202
+
203
+ await self._state.add_playlist_item(item, after)
204
+
205
+ except Exception as e:
206
+ self._logger.error(f"Error handling queue event: {e}", exc_info=True)
207
+
208
+ async def _handle_delete(self, msg) -> None:
209
+ """Handle 'delete' event (video removed from playlist).
210
+
211
+ Args:
212
+ msg: NATS message with delete data.
213
+ """
214
+ try:
215
+ import json
216
+ data = json.loads(msg.data.decode())
217
+ payload = data.get("payload", {})
218
+
219
+ uid = payload.get("uid")
220
+ if uid:
221
+ await self._state.remove_playlist_item(uid)
222
+
223
+ except Exception as e:
224
+ self._logger.error(f"Error handling delete event: {e}", exc_info=True)
225
+
226
+ async def _handle_move_media(self, msg) -> None:
227
+ """Handle 'moveMedia' event (video moved in playlist).
228
+
229
+ Args:
230
+ msg: NATS message with move data.
231
+ """
232
+ try:
233
+ import json
234
+ data = json.loads(msg.data.decode())
235
+ payload = data.get("payload", {})
236
+
237
+ from_uid = payload.get("from")
238
+ after = payload.get("after")
239
+
240
+ if from_uid is not None and after is not None:
241
+ await self._state.move_playlist_item(from_uid, after)
242
+
243
+ except Exception as e:
244
+ self._logger.error(f"Error handling moveMedia event: {e}", exc_info=True)
245
+
246
+ async def _handle_userlist(self, msg) -> None:
247
+ """Handle 'userlist' event (initial user list load).
248
+
249
+ Args:
250
+ msg: NATS message with userlist data.
251
+ """
252
+ try:
253
+ import json
254
+ data = json.loads(msg.data.decode())
255
+
256
+ # Extract users from event payload
257
+ users = data.get("payload", [])
258
+
259
+ await self._state.set_userlist(users)
260
+
261
+ except Exception as e:
262
+ self._logger.error(f"Error handling userlist event: {e}", exc_info=True)
263
+
264
+ async def _handle_add_user(self, msg) -> None:
265
+ """Handle 'addUser' event (user joined channel).
266
+
267
+ Args:
268
+ msg: NATS message with user data.
269
+ """
270
+ try:
271
+ import json
272
+ data = json.loads(msg.data.decode())
273
+ payload = data.get("payload", {})
274
+
275
+ await self._state.add_user(payload)
276
+
277
+ except Exception as e:
278
+ self._logger.error(f"Error handling addUser event: {e}", exc_info=True)
279
+
280
+ async def _handle_user_leave(self, msg) -> None:
281
+ """Handle 'userLeave' event (user left channel).
282
+
283
+ Args:
284
+ msg: NATS message with username data.
285
+ """
286
+ try:
287
+ import json
288
+ data = json.loads(msg.data.decode())
289
+ payload = data.get("payload", {})
290
+
291
+ username = payload.get("name")
292
+ if username:
293
+ await self._state.remove_user(username)
294
+
295
+ except Exception as e:
296
+ self._logger.error(f"Error handling userLeave event: {e}", exc_info=True)
297
+
298
+ async def _handle_emote_list(self, msg) -> None:
299
+ """Handle 'emoteList' event (channel emotes loaded).
300
+
301
+ Args:
302
+ msg: NATS message with emote list data.
303
+ """
304
+ try:
305
+ import json
306
+ data = json.loads(msg.data.decode())
307
+
308
+ # Extract emotes from event payload
309
+ emotes = data.get("payload", [])
310
+
311
+ await self._state.update_emotes(emotes)
312
+
313
+ except Exception as e:
314
+ self._logger.error(f"Error handling emoteList event: {e}", exc_info=True)
@@ -0,0 +1,108 @@
1
+ """Statistics Tracker - Track rates and statistics over time windows.
2
+
3
+ This module provides the StatsTracker class for monitoring event rates and
4
+ maintaining time-windowed statistics for system management and monitoring.
5
+ """
6
+
7
+ import time
8
+ from collections import deque
9
+
10
+
11
+ class StatsTracker:
12
+ """Track rates and statistics over time windows.
13
+
14
+ Maintains a rolling window of events with timestamps and types,
15
+ enabling rate calculation over various time periods and access to
16
+ the most recent event information.
17
+
18
+ Attributes:
19
+ window_size: Maximum number of events to track (default 300 for 5 min at 1/sec).
20
+
21
+ Examples:
22
+ >>> tracker = StatsTracker()
23
+ >>> tracker.record("chatMsg")
24
+ >>> tracker.record("userJoin")
25
+ >>> print(f"Rate: {tracker.get_rate(60):.2f}/sec")
26
+ >>> last_time, last_type = tracker.get_last()
27
+ """
28
+
29
+ def __init__(self, window_size: int = 300):
30
+ """Initialize statistics tracker.
31
+
32
+ Args:
33
+ window_size: Maximum events to track (default 300 = 5 min at 1/sec).
34
+ """
35
+ self._events: deque[tuple[float, str | None]] = deque(maxlen=window_size)
36
+ self._start_time = time.time()
37
+
38
+ def record(self, event_type: str | None = None) -> None:
39
+ """Record an event occurrence.
40
+
41
+ Args:
42
+ event_type: Optional event type/name for tracking.
43
+
44
+ Examples:
45
+ >>> tracker.record("chatMsg")
46
+ >>> tracker.record() # Anonymous event
47
+ """
48
+ self._events.append((time.time(), event_type))
49
+
50
+ def get_rate(self, window_seconds: int) -> float:
51
+ """Get events per second over specified time window.
52
+
53
+ Args:
54
+ window_seconds: Time window in seconds to calculate rate over.
55
+
56
+ Returns:
57
+ Events per second over the window, or 0.0 if window is invalid.
58
+
59
+ Examples:
60
+ >>> rate_1min = tracker.get_rate(60)
61
+ >>> rate_5min = tracker.get_rate(300)
62
+ """
63
+ if window_seconds <= 0:
64
+ return 0.0
65
+
66
+ cutoff = time.time() - window_seconds
67
+ count = sum(1 for t, _ in self._events if t > cutoff)
68
+ return count / window_seconds
69
+
70
+ def get_last(self) -> tuple[float | None, str | None]:
71
+ """Get last event time and type.
72
+
73
+ Returns:
74
+ Tuple of (timestamp, event_type) for most recent event,
75
+ or (None, None) if no events recorded.
76
+
77
+ Examples:
78
+ >>> timestamp, event_type = tracker.get_last()
79
+ >>> if timestamp:
80
+ ... print(f"Last: {event_type} at {timestamp}")
81
+ """
82
+ if self._events:
83
+ return self._events[-1]
84
+ return (None, None)
85
+
86
+ def get_total(self) -> int:
87
+ """Get total events recorded (within window).
88
+
89
+ Returns:
90
+ Number of events currently in the tracking window.
91
+
92
+ Examples:
93
+ >>> total = tracker.get_total()
94
+ >>> print(f"Tracked events: {total}")
95
+ """
96
+ return len(self._events)
97
+
98
+ def get_uptime(self) -> float:
99
+ """Get tracker uptime in seconds.
100
+
101
+ Returns:
102
+ Seconds since tracker was created.
103
+
104
+ Examples:
105
+ >>> uptime = tracker.get_uptime()
106
+ >>> print(f"Uptime: {uptime / 3600:.1f} hours")
107
+ """
108
+ return time.time() - self._start_time