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,711 @@
1
+ """State Manager - Persist CyTube channel state to NATS KV stores.
2
+
3
+ This module tracks and persists channel state (emotes, playlist, userlist)
4
+ to NATS key-value stores, allowing downstream applications to query state
5
+ without directly connecting to the CyTube instance.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from typing import Any
11
+
12
+ from nats.errors import NoRespondersError
13
+ from nats.js import api
14
+ from nats.js.errors import ServiceUnavailableError
15
+ from nats.js.kv import KeyValue
16
+
17
+ from .nats_client import NatsClient
18
+
19
+
20
+ class StateManager:
21
+ """Manage CyTube channel state in NATS key-value stores.
22
+
23
+ Maintains three KV buckets for channel state:
24
+ - emotes: Channel emote list
25
+ - playlist: Current playlist items
26
+ - userlist: Connected users
27
+
28
+ Attributes:
29
+ nats_client: NATS client for KV operations.
30
+ channel: CyTube channel name.
31
+ logger: Logger instance.
32
+ is_running: Whether state manager is active.
33
+
34
+ Examples:
35
+ >>> manager = StateManager(nats_client, "mychannel", logger)
36
+ >>> await manager.start()
37
+ >>> await manager.update_emotes(emote_list)
38
+ >>> await manager.stop()
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ nats_client: NatsClient,
44
+ channel: str,
45
+ logger: logging.Logger,
46
+ counting_config=None,
47
+ ):
48
+ """Initialize state manager.
49
+
50
+ Args:
51
+ nats_client: NATS client instance.
52
+ channel: CyTube channel name.
53
+ logger: Logger for structured output.
54
+ counting_config: Optional StateCountingConfig for filtering counts.
55
+ """
56
+ self._nats = nats_client
57
+ self._channel = channel
58
+ self._logger = logger
59
+ self._counting_config = counting_config
60
+ self._running = False
61
+
62
+ # KV bucket handles
63
+ self._kv_emotes: KeyValue | None = None
64
+ self._kv_playlist: KeyValue | None = None
65
+ self._kv_userlist: KeyValue | None = None
66
+
67
+ # State tracking
68
+ self._emotes: list[dict[str, Any]] = []
69
+ self._playlist: list[dict[str, Any]] = []
70
+ self._users: dict[str, dict[str, Any]] = {} # username -> user data
71
+
72
+ @property
73
+ def is_running(self) -> bool:
74
+ """Check if state manager is running.
75
+
76
+ Returns:
77
+ True if started and managing state, False otherwise.
78
+ """
79
+ return self._running
80
+
81
+ def users_count(self) -> int:
82
+ """Get count of users with optional filtering.
83
+
84
+ Applies filters from counting_config:
85
+ - users_exclude_afk: Exclude AFK users
86
+ - users_min_rank: Minimum rank to include
87
+
88
+ Returns:
89
+ Filtered count of users.
90
+
91
+ Examples:
92
+ >>> count = manager.users_count()
93
+ >>> print(f"Active users: {count}")
94
+ """
95
+ if not self._counting_config:
96
+ return len(self._users)
97
+
98
+ count = 0
99
+ for user in self._users.values():
100
+ # Check rank filter
101
+ user_rank = user.get("rank", 0)
102
+ if user_rank < self._counting_config.users_min_rank:
103
+ continue
104
+
105
+ # Check AFK filter
106
+ if self._counting_config.users_exclude_afk:
107
+ meta = user.get("meta", {})
108
+ if meta.get("afk", False):
109
+ continue
110
+
111
+ count += 1
112
+
113
+ return count
114
+
115
+ def playlist_count(self) -> int:
116
+ """Get count of playlist items with optional filtering.
117
+
118
+ Applies filters from counting_config:
119
+ - playlist_exclude_temp: Exclude temporary items
120
+ - playlist_max_duration: Maximum duration in seconds (0=no limit)
121
+
122
+ Returns:
123
+ Filtered count of playlist items.
124
+
125
+ Examples:
126
+ >>> count = manager.playlist_count()
127
+ >>> print(f"Playlist items: {count}")
128
+ """
129
+ if not self._counting_config:
130
+ return len(self._playlist)
131
+
132
+ count = 0
133
+ for item in self._playlist:
134
+ # Check temp filter
135
+ if self._counting_config.playlist_exclude_temp:
136
+ if item.get("temp", False):
137
+ continue
138
+
139
+ # Check duration filter
140
+ if self._counting_config.playlist_max_duration > 0:
141
+ media = item.get("media", {})
142
+ duration = media.get("seconds", 0)
143
+ if duration > self._counting_config.playlist_max_duration:
144
+ continue
145
+
146
+ count += 1
147
+
148
+ return count
149
+
150
+ def emotes_count(self) -> int:
151
+ """Get count of emotes with optional filtering.
152
+
153
+ Applies filters from counting_config:
154
+ - emotes_only_enabled: Only count enabled emotes
155
+
156
+ Returns:
157
+ Filtered count of emotes.
158
+
159
+ Examples:
160
+ >>> count = manager.emotes_count()
161
+ >>> print(f"Emotes: {count}")
162
+ """
163
+ if not self._counting_config:
164
+ return len(self._emotes)
165
+
166
+ if not self._counting_config.emotes_only_enabled:
167
+ return len(self._emotes)
168
+
169
+ # Count only enabled emotes
170
+ count = 0
171
+ for emote in self._emotes:
172
+ if not emote.get("disabled", False):
173
+ count += 1
174
+
175
+ return count
176
+
177
+ @property
178
+ def stats(self) -> dict[str, int]:
179
+ """Get state statistics using configured counting filters.
180
+
181
+ Returns:
182
+ Dictionary with emote_count, playlist_count, user_count.
183
+ """
184
+ return {
185
+ "emote_count": self.emotes_count(),
186
+ "playlist_count": self.playlist_count(),
187
+ "user_count": self.users_count(),
188
+ }
189
+
190
+ async def start(self) -> None:
191
+ """Start state manager and create KV buckets.
192
+
193
+ Creates or binds to NATS JetStream KV buckets for state storage.
194
+ Buckets are named: cytube_{channel}_emotes, cytube_{channel}_playlist,
195
+ cytube_{channel}_userlist.
196
+
197
+ Raises:
198
+ RuntimeError: If NATS is not connected or JetStream unavailable.
199
+ """
200
+ if self._running:
201
+ self._logger.debug("State manager already running")
202
+ return
203
+
204
+ if not self._nats.is_connected:
205
+ raise RuntimeError("NATS client not connected")
206
+
207
+ try:
208
+ self._logger.info(f"Starting state manager for channel: {self._channel}")
209
+
210
+ # Get JetStream context
211
+ js = self._nats._nc.jetstream()
212
+
213
+ # Create or bind KV buckets
214
+ # Format: kryten_{channel}_{type}
215
+ bucket_prefix = f"kryten_{self._channel}"
216
+
217
+ # Emotes bucket
218
+ try:
219
+ self._kv_emotes = await js.key_value(bucket=f"{bucket_prefix}_emotes")
220
+ self._logger.debug("Bound to existing emotes KV bucket")
221
+ except Exception:
222
+ self._kv_emotes = await js.create_key_value(
223
+ config=api.KeyValueConfig(
224
+ bucket=f"{bucket_prefix}_emotes",
225
+ description=f"Kryten {self._channel} emotes",
226
+ max_value_size=1024 * 1024, # 1MB max
227
+ )
228
+ )
229
+ self._logger.info("Created emotes KV bucket")
230
+
231
+ # Playlist bucket
232
+ try:
233
+ self._kv_playlist = await js.key_value(bucket=f"{bucket_prefix}_playlist")
234
+ self._logger.debug("Bound to existing playlist KV bucket")
235
+ except Exception:
236
+ self._kv_playlist = await js.create_key_value(
237
+ config=api.KeyValueConfig(
238
+ bucket=f"{bucket_prefix}_playlist",
239
+ description=f"Kryten {self._channel} playlist",
240
+ max_value_size=10 * 1024 * 1024, # 10MB max
241
+ )
242
+ )
243
+ self._logger.info("Created playlist KV bucket")
244
+
245
+ # Userlist bucket
246
+ try:
247
+ self._kv_userlist = await js.key_value(bucket=f"{bucket_prefix}_userlist")
248
+ self._logger.debug("Bound to existing userlist KV bucket")
249
+ except Exception:
250
+ self._kv_userlist = await js.create_key_value(
251
+ config=api.KeyValueConfig(
252
+ bucket=f"{bucket_prefix}_userlist",
253
+ description=f"Kryten {self._channel} users",
254
+ max_value_size=1024 * 1024, # 1MB max
255
+ )
256
+ )
257
+ self._logger.info("Created userlist KV bucket")
258
+
259
+ self._running = True
260
+ self._logger.info("State manager started")
261
+
262
+ except (ServiceUnavailableError, NoRespondersError) as e:
263
+ self._logger.error(
264
+ "JetStream not available - state persistence disabled. "
265
+ "Ensure NATS server is running with JetStream enabled (use -js flag)."
266
+ )
267
+ raise RuntimeError(
268
+ "JetStream not available. NATS server must be started with JetStream enabled. "
269
+ "Run 'nats-server -js' or configure JetStream in nats-server.conf"
270
+ ) from e
271
+
272
+ except Exception as e:
273
+ self._logger.error(f"Failed to start state manager: {e}", exc_info=True)
274
+ raise
275
+
276
+ async def stop(self) -> None:
277
+ """Stop state manager.
278
+
279
+ Does not delete KV buckets - state persists for downstream consumers.
280
+ """
281
+ if not self._running:
282
+ return
283
+
284
+ self._logger.info("Stopping state manager")
285
+
286
+ self._kv_emotes = None
287
+ self._kv_playlist = None
288
+ self._kv_userlist = None
289
+ self._running = False
290
+
291
+ self._logger.info("State manager stopped")
292
+
293
+ # ========================================================================
294
+ # Emote Management
295
+ # ========================================================================
296
+
297
+ async def update_emotes(self, emotes: list[dict[str, Any]]) -> None:
298
+ """Update full emote list.
299
+
300
+ Called when 'emoteList' event received from CyTube.
301
+
302
+ Args:
303
+ emotes: List of emote objects with 'name', 'image', etc.
304
+
305
+ Examples:
306
+ >>> emotes = [{"name": "Kappa", "image": "..."}]
307
+ >>> await manager.update_emotes(emotes)
308
+ """
309
+ if not self._running:
310
+ self._logger.warning("Cannot update emotes: state manager not running")
311
+ return
312
+
313
+ try:
314
+ self._emotes = emotes
315
+
316
+ # Store as JSON
317
+ emotes_json = json.dumps(emotes).encode()
318
+ await self._kv_emotes.put("list", emotes_json)
319
+
320
+ self._logger.info(f"Updated emotes: {len(emotes)} emotes")
321
+
322
+ except Exception as e:
323
+ self._logger.error(f"Failed to update emotes: {e}", exc_info=True)
324
+
325
+ # ========================================================================
326
+ # Playlist Management
327
+ # ========================================================================
328
+
329
+ async def set_playlist(self, playlist: list[dict[str, Any]]) -> None:
330
+ """Set entire playlist.
331
+
332
+ Called when 'playlist' event received (initial load).
333
+
334
+ Args:
335
+ playlist: List of media items with 'uid', 'title', 'duration', etc.
336
+
337
+ Examples:
338
+ >>> items = [{"uid": "abc", "title": "Video 1"}]
339
+ >>> await manager.set_playlist(items)
340
+ """
341
+ if not self._running:
342
+ self._logger.warning("Cannot set playlist: state manager not running")
343
+ return
344
+
345
+ try:
346
+ self._playlist = playlist
347
+
348
+ # Store as JSON
349
+ playlist_json = json.dumps(playlist).encode()
350
+ await self._kv_playlist.put("items", playlist_json)
351
+
352
+ self._logger.info(f"Set playlist: {len(playlist)} items")
353
+
354
+ except Exception as e:
355
+ self._logger.error(f"Failed to set playlist: {e}", exc_info=True)
356
+
357
+ async def add_playlist_item(self, item: dict[str, Any], after: str | None = None) -> None:
358
+ """Add item to playlist.
359
+
360
+ Called when 'queue' event received.
361
+
362
+ Args:
363
+ item: Media item to add.
364
+ after: UID of item to insert after, or None for end.
365
+
366
+ Examples:
367
+ >>> item = {"uid": "xyz", "title": "New Video"}
368
+ >>> await manager.add_playlist_item(item)
369
+ """
370
+ if not self._running:
371
+ return
372
+
373
+ try:
374
+ if after is None:
375
+ # Append to end
376
+ self._playlist.append(item)
377
+ else:
378
+ # Insert after specified UID
379
+ for i, existing in enumerate(self._playlist):
380
+ if existing.get("uid") == after:
381
+ self._playlist.insert(i + 1, item)
382
+ break
383
+ else:
384
+ # UID not found, append
385
+ self._playlist.append(item)
386
+
387
+ # Update KV store
388
+ playlist_json = json.dumps(self._playlist).encode()
389
+ await self._kv_playlist.put("items", playlist_json)
390
+
391
+ self._logger.debug(f"Added playlist item: {item.get('uid')} ({item.get('title', 'Unknown')})")
392
+
393
+ except Exception as e:
394
+ self._logger.error(f"Failed to add playlist item: {e}", exc_info=True)
395
+
396
+ async def remove_playlist_item(self, uid: str) -> None:
397
+ """Remove item from playlist.
398
+
399
+ Called when 'delete' event received.
400
+
401
+ Args:
402
+ uid: UID of item to remove.
403
+
404
+ Examples:
405
+ >>> await manager.remove_playlist_item("xyz")
406
+ """
407
+ if not self._running:
408
+ return
409
+
410
+ try:
411
+ self._playlist = [item for item in self._playlist if item.get("uid") != uid]
412
+
413
+ # Update KV store
414
+ playlist_json = json.dumps(self._playlist).encode()
415
+ await self._kv_playlist.put("items", playlist_json)
416
+
417
+ self._logger.debug(f"Removed playlist item: {uid}")
418
+
419
+ except Exception as e:
420
+ self._logger.error(f"Failed to remove playlist item: {e}", exc_info=True)
421
+
422
+ async def move_playlist_item(self, uid: str, after: str) -> None:
423
+ """Move item in playlist.
424
+
425
+ Called when 'moveMedia' event received.
426
+
427
+ Args:
428
+ uid: UID of item to move.
429
+ after: UID to place after, or "prepend"/"append".
430
+
431
+ Examples:
432
+ >>> await manager.move_playlist_item("xyz", "abc")
433
+ """
434
+ if not self._running:
435
+ return
436
+
437
+ try:
438
+ # Find and remove item
439
+ item = None
440
+ for i, existing in enumerate(self._playlist):
441
+ if existing.get("uid") == uid:
442
+ item = self._playlist.pop(i)
443
+ break
444
+
445
+ if item is None:
446
+ self._logger.warning(f"Cannot move item {uid}: not found")
447
+ return
448
+
449
+ # Insert at new position
450
+ if after == "prepend":
451
+ self._playlist.insert(0, item)
452
+ elif after == "append":
453
+ self._playlist.append(item)
454
+ else:
455
+ # Insert after specified UID
456
+ for i, existing in enumerate(self._playlist):
457
+ if existing.get("uid") == after:
458
+ self._playlist.insert(i + 1, item)
459
+ break
460
+ else:
461
+ # UID not found, append
462
+ self._playlist.append(item)
463
+
464
+ # Update KV store
465
+ playlist_json = json.dumps(self._playlist).encode()
466
+ await self._kv_playlist.put("items", playlist_json)
467
+
468
+ self._logger.debug(f"Moved playlist item {uid} after {after}")
469
+
470
+ except Exception as e:
471
+ self._logger.error(f"Failed to move playlist item: {e}", exc_info=True)
472
+
473
+ async def clear_playlist(self) -> None:
474
+ """Clear entire playlist.
475
+
476
+ Called when 'playlist' event with empty list received.
477
+ """
478
+ if not self._running:
479
+ return
480
+
481
+ try:
482
+ self._playlist = []
483
+
484
+ # Update KV store
485
+ playlist_json = json.dumps([]).encode()
486
+ await self._kv_playlist.put("items", playlist_json)
487
+
488
+ self._logger.debug("Cleared playlist")
489
+
490
+ except Exception as e:
491
+ self._logger.error(f"Failed to clear playlist: {e}", exc_info=True)
492
+
493
+ # ========================================================================
494
+ # Userlist Management
495
+ # ========================================================================
496
+
497
+ async def set_userlist(self, users: list[dict[str, Any]]) -> None:
498
+ """Set entire userlist.
499
+
500
+ Called when 'userlist' event received (initial load).
501
+
502
+ Args:
503
+ users: List of user objects with 'name', 'rank', etc.
504
+
505
+ Examples:
506
+ >>> users = [{"name": "Alice", "rank": 2}]
507
+ >>> await manager.set_userlist(users)
508
+ """
509
+ if not self._running:
510
+ self._logger.warning("Cannot set userlist: state manager not running")
511
+ return
512
+
513
+ try:
514
+ self._users = {user.get("name"): user for user in users if user.get("name")}
515
+
516
+ # Store as JSON
517
+ userlist_json = json.dumps(list(self._users.values())).encode()
518
+ await self._kv_userlist.put("users", userlist_json)
519
+
520
+ self._logger.info(f"Set userlist: {len(self._users)} users")
521
+
522
+ except Exception as e:
523
+ self._logger.error(f"Failed to set userlist: {e}", exc_info=True)
524
+
525
+ async def add_user(self, user: dict[str, Any]) -> None:
526
+ """Add user to userlist.
527
+
528
+ Called when 'addUser' event received.
529
+
530
+ Args:
531
+ user: User object with 'name', 'rank', etc.
532
+
533
+ Examples:
534
+ >>> user = {"name": "Bob", "rank": 1}
535
+ >>> await manager.add_user(user)
536
+ """
537
+ if not self._running:
538
+ return
539
+
540
+ try:
541
+ username = user.get("name")
542
+ if not username:
543
+ return
544
+
545
+ self._users[username] = user
546
+
547
+ # Update KV store
548
+ userlist_json = json.dumps(list(self._users.values())).encode()
549
+ await self._kv_userlist.put("users", userlist_json)
550
+
551
+ self._logger.debug(f"Added user: {username}")
552
+
553
+ except Exception as e:
554
+ self._logger.error(f"Failed to add user: {e}", exc_info=True)
555
+
556
+ async def remove_user(self, username: str) -> None:
557
+ """Remove user from userlist.
558
+
559
+ Called when 'userLeave' event received.
560
+
561
+ Args:
562
+ username: Username to remove.
563
+
564
+ Examples:
565
+ >>> await manager.remove_user("Bob")
566
+ """
567
+ if not self._running:
568
+ return
569
+
570
+ try:
571
+ if username in self._users:
572
+ del self._users[username]
573
+
574
+ # Update KV store
575
+ userlist_json = json.dumps(list(self._users.values())).encode()
576
+ await self._kv_userlist.put("users", userlist_json)
577
+
578
+ self._logger.debug(f"Removed user: {username}")
579
+
580
+ except Exception as e:
581
+ self._logger.error(f"Failed to remove user: {e}", exc_info=True)
582
+
583
+ async def update_user(self, user: dict[str, Any]) -> None:
584
+ """Update user data.
585
+
586
+ Called when user properties change (rank, meta, etc).
587
+
588
+ Args:
589
+ user: Updated user object.
590
+
591
+ Examples:
592
+ >>> user = {"name": "Bob", "rank": 3}
593
+ >>> await manager.update_user(user)
594
+ """
595
+ if not self._running:
596
+ return
597
+
598
+ try:
599
+ username = user.get("name")
600
+ if not username:
601
+ return
602
+
603
+ self._users[username] = user
604
+
605
+ # Update KV store
606
+ userlist_json = json.dumps(list(self._users.values())).encode()
607
+ await self._kv_userlist.put("users", userlist_json)
608
+
609
+ self._logger.debug(f"Updated user: {username}")
610
+
611
+ except Exception as e:
612
+ self._logger.error(f"Failed to update user: {e}", exc_info=True)
613
+
614
+ # ========================================================================
615
+ # State Retrieval
616
+ # ========================================================================
617
+
618
+ def get_emotes(self) -> list[dict[str, Any]]:
619
+ """Get current emote list.
620
+
621
+ Returns:
622
+ List of emote dictionaries.
623
+ """
624
+ return self._emotes.copy()
625
+
626
+ def get_playlist(self) -> list[dict[str, Any]]:
627
+ """Get current playlist.
628
+
629
+ Returns:
630
+ List of playlist item dictionaries.
631
+ """
632
+ return self._playlist.copy()
633
+
634
+ def get_userlist(self) -> list[dict[str, Any]]:
635
+ """Get current userlist.
636
+
637
+ Returns:
638
+ List of user dictionaries.
639
+ """
640
+ return list(self._users.values())
641
+
642
+ def get_user(self, username: str) -> dict[str, Any] | None:
643
+ """Get specific user by username.
644
+
645
+ Args:
646
+ username: Username to look up.
647
+
648
+ Returns:
649
+ User dictionary if found, None otherwise.
650
+
651
+ Examples:
652
+ >>> user = manager.get_user("Alice")
653
+ >>> if user:
654
+ ... print(f"Rank: {user['rank']}")
655
+ """
656
+ return self._users.get(username)
657
+
658
+ def get_user_profile(self, username: str) -> dict[str, Any] | None:
659
+ """Get user's profile (avatar and bio).
660
+
661
+ Args:
662
+ username: Username to look up.
663
+
664
+ Returns:
665
+ Profile dictionary with 'image' and 'text' keys, or None if not found.
666
+
667
+ Examples:
668
+ >>> profile = manager.get_user_profile("Alice")
669
+ >>> if profile:
670
+ ... print(f"Avatar: {profile.get('image')}")
671
+ ... print(f"Bio: {profile.get('text')}")
672
+ """
673
+ user = self._users.get(username)
674
+ if user:
675
+ return user.get("profile", {})
676
+ return None
677
+
678
+ def get_all_profiles(self) -> dict[str, dict[str, Any]]:
679
+ """Get all user profiles.
680
+
681
+ Returns:
682
+ Dictionary mapping username to profile dict.
683
+
684
+ Examples:
685
+ >>> profiles = manager.get_all_profiles()
686
+ >>> for username, profile in profiles.items():
687
+ ... print(f"{username}: {profile.get('image')}")
688
+ """
689
+ profiles = {}
690
+ for username, user in self._users.items():
691
+ profile = user.get("profile")
692
+ if profile:
693
+ profiles[username] = profile
694
+ return profiles
695
+
696
+ def get_all_state(self) -> dict[str, list[dict[str, Any]]]:
697
+ """Get all channel state.
698
+
699
+ Returns:
700
+ Dictionary with emotes, playlist, and userlist.
701
+ """
702
+ return {
703
+ "emotes": self.get_emotes(),
704
+ "playlist": self.get_playlist(),
705
+ "userlist": self.get_userlist()
706
+ }
707
+
708
+
709
+ __all__ = ["StateManager"]
710
+
711
+