kryten-cli 2.1.1__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,313 @@
1
+ Metadata-Version: 2.4
2
+ Name: kryten-cli
3
+ Version: 2.1.1
4
+ Summary: Command-line client for CyTube via kryten-py library
5
+ Home-page: https://github.com/grobertson/kryten-cli
6
+ Author: Kryten Robot Team
7
+ Author-email: Kryten Robot Team <kryten@example.com>
8
+ License: MIT
9
+ Project-URL: Homepage, https://github.com/grobertson/kryten-cli
10
+ Project-URL: Bug Tracker, https://github.com/grobertson/kryten-cli/issues
11
+ Project-URL: Source Code, https://github.com/grobertson/kryten-cli
12
+ Project-URL: Documentation, https://github.com/grobertson/kryten-cli/blob/main/README.md
13
+ Keywords: cytube,nats,cli,command-line,microservices,bot
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Communications :: Chat
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: kryten-py>=0.5.7
27
+ Dynamic: author
28
+ Dynamic: home-page
29
+ Dynamic: license-file
30
+ Dynamic: requires-python
31
+
32
+ # Kryten CLI
33
+
34
+ Command-line client for sending CyTube commands via the kryten-py library.
35
+
36
+ ## Overview
37
+
38
+ This CLI provides a simple command-line interface to control CyTube channels through the Kryten bridge. It uses the high-level `kryten-py` library for all communication, making it a clean and maintainable reference implementation.
39
+
40
+ ## Installation
41
+
42
+ Install the CLI tool:
43
+
44
+ ```bash
45
+ pip install -e .
46
+ ```
47
+
48
+ This will automatically install the `kryten-py` dependency.
49
+
50
+ Or run directly:
51
+
52
+ ```bash
53
+ python kryten_cli.py --help
54
+ ```
55
+
56
+ ## Configuration
57
+
58
+ The CLI reads connection settings from `config.json` in the current directory. Create one with your NATS server and CyTube channel:
59
+
60
+ ```json
61
+ {
62
+ "nats": {
63
+ "servers": ["nats://localhost:4222"]
64
+ },
65
+ "channels": [
66
+ {
67
+ "domain": "cytu.be",
68
+ "channel": "your-channel-name"
69
+ }
70
+ ]
71
+ }
72
+ ```
73
+
74
+ **Legacy Format Support:** The CLI also supports the older config format with `cytube.channel` for backward compatibility.
75
+
76
+ You can also specify a different config file:
77
+
78
+ ```bash
79
+ kryten --config /path/to/config.json say "Hello"
80
+ ```
81
+
82
+ ## Usage Examples
83
+
84
+ ### Chat Commands
85
+
86
+ Send a chat message:
87
+ ```bash
88
+ kryten say "Hello world"
89
+ ```
90
+
91
+ Send a private message:
92
+ ```bash
93
+ kryten pm UserName "Hi there!"
94
+ ```
95
+
96
+ ### Playlist Commands
97
+
98
+ Add video to end of playlist:
99
+ ```bash
100
+ kryten playlist add https://youtube.com/watch?v=xyz
101
+ kryten playlist add yt:abc123
102
+ ```
103
+
104
+ Add video to play next:
105
+ ```bash
106
+ kryten playlist addnext https://youtube.com/watch?v=abc
107
+ ```
108
+
109
+ Add as temporary (auto-deleted after playing):
110
+ ```bash
111
+ kryten playlist add --temp https://youtube.com/watch?v=xyz
112
+ ```
113
+
114
+ Delete video from playlist:
115
+ ```bash
116
+ kryten playlist del video-uid-123
117
+ ```
118
+
119
+ Move video in playlist:
120
+ ```bash
121
+ kryten playlist move video-uid-5 after video-uid-3
122
+ ```
123
+
124
+ Jump to specific video:
125
+ ```bash
126
+ kryten playlist jump video-uid-7
127
+ ```
128
+
129
+ Clear entire playlist:
130
+ ```bash
131
+ kryten playlist clear
132
+ ```
133
+
134
+ Shuffle playlist:
135
+ ```bash
136
+ kryten playlist shuffle
137
+ ```
138
+
139
+ Set video temporary status:
140
+ ```bash
141
+ kryten playlist settemp video-uid-5 true
142
+ kryten playlist settemp video-uid-5 false
143
+ ```
144
+
145
+ ### Playback Commands
146
+
147
+ Pause current video:
148
+ ```bash
149
+ kryten pause
150
+ ```
151
+
152
+ Resume playback:
153
+ ```bash
154
+ kryten play
155
+ ```
156
+
157
+ Seek to timestamp (in seconds):
158
+ ```bash
159
+ kryten seek 120.5
160
+ ```
161
+
162
+ ### Moderation Commands
163
+
164
+ Kick user:
165
+ ```bash
166
+ kryten kick UserName
167
+ kryten kick UserName "Stop spamming"
168
+ ```
169
+
170
+ Ban user:
171
+ ```bash
172
+ kryten ban UserName
173
+ kryten ban UserName "Banned for harassment"
174
+ ```
175
+
176
+ Vote to skip current video:
177
+ ```bash
178
+ kryten voteskip
179
+ ```
180
+
181
+ ## Command Reference
182
+
183
+ | Command | Description |
184
+ |---------|-------------|
185
+ | `say <message>` | Send a chat message |
186
+ | `pm <user> <message>` | Send a private message |
187
+ | `playlist add <url>` | Add video to end of playlist |
188
+ | `playlist addnext <url>` | Add video to play next |
189
+ | `playlist del <uid>` | Delete video from playlist |
190
+ | `playlist move <uid> after <uid>` | Move video in playlist |
191
+ | `playlist jump <uid>` | Jump to specific video |
192
+ | `playlist clear` | Clear entire playlist |
193
+ | `playlist shuffle` | Shuffle playlist |
194
+ | `playlist settemp <uid> <true\|false>` | Set temporary status |
195
+ | `pause` | Pause playback |
196
+ | `play` | Resume playback |
197
+ | `seek <seconds>` | Seek to timestamp |
198
+ | `kick <user> [reason]` | Kick user from channel |
199
+ | `ban <user> [reason]` | Ban user from channel |
200
+ | `voteskip` | Vote to skip current video |
201
+
202
+ ## Options
203
+
204
+ | Option | Description |
205
+ |--------|-------------|
206
+ | `--config <path>` | Path to config file (default: config.json) |
207
+ | `--channel <name>` | Override channel from config |
208
+ | `--help` | Show help message |
209
+
210
+ ## NATS Message Format
211
+
212
+ All commands are published to NATS subjects following this pattern:
213
+
214
+ ```
215
+ cytube.commands.{channel}.{action}
216
+ ```
217
+
218
+ Message payload:
219
+ ```json
220
+ {
221
+ "action": "chat",
222
+ "data": {
223
+ "message": "Hello world"
224
+ }
225
+ }
226
+ ```
227
+
228
+ This format is compatible with the Kryten bidirectional bridge's `CommandSubscriber`.
229
+
230
+ ## Requirements
231
+
232
+ - Python 3.11+
233
+ - nats-py >= 2.9.0
234
+ - Kryten bidirectional bridge running with `commands.enabled = true`
235
+
236
+ ## Examples
237
+
238
+ ### Automated DJ
239
+
240
+ Add a list of videos:
241
+ ```bash
242
+ for url in $(cat playlist.txt); do
243
+ kryten playlist add "$url"
244
+ sleep 1
245
+ done
246
+ ```
247
+
248
+ ### Bot Integration
249
+
250
+ Use in scripts to respond to events:
251
+ ```bash
252
+ #!/bin/bash
253
+ # Greet new users
254
+ kryten say "Welcome to the channel!"
255
+ ```
256
+
257
+ ### Remote Moderation
258
+
259
+ Quick moderation commands:
260
+ ```bash
261
+ kryten kick TrollUser "Please follow the rules"
262
+ kryten ban SpamBot "Automated spam detected"
263
+ ```
264
+
265
+ ## Troubleshooting
266
+
267
+ ### Connection refused
268
+
269
+ Make sure NATS server is running:
270
+ ```bash
271
+ # Start NATS server
272
+ nats-server
273
+ ```
274
+
275
+ ### Command not found
276
+
277
+ Make sure the CLI is installed:
278
+ ```bash
279
+ pip install -e .
280
+ ```
281
+
282
+ Or use the module directly:
283
+ ```bash
284
+ python kryten_cli.py say "Hello"
285
+ ```
286
+
287
+ ### Commands not executing
288
+
289
+ 1. Check that Kryten bridge is running
290
+ 2. Verify `commands.enabled = true` in Kryten's config
291
+ 3. Check NATS connection settings match between CLI and Kryten
292
+ 4. Verify channel name is correct
293
+
294
+ ## Version 2.0 - Using kryten-py Library
295
+
296
+ **Version 2.0** is a complete rewrite that uses the `kryten-py` library instead of direct NATS calls. This provides:
297
+
298
+ - **Cleaner code**: High-level API instead of low-level NATS
299
+ - **Type safety**: Typed interfaces and better IDE support
300
+ - **Better maintenance**: Shares code with other kryten projects
301
+ - **New features**: Automatic URL parsing for media commands
302
+
303
+ For migration information from v1.x, see [MIGRATION.md](MIGRATION.md).
304
+
305
+ For complete details about the refactor, see [REFACTOR_SUMMARY.md](REFACTOR_SUMMARY.md).
306
+
307
+ ## Contributing
308
+
309
+ This project serves as a reference implementation for using kryten-py. Contributions are welcome!
310
+
311
+ ## License
312
+
313
+ See LICENSE file for details.
@@ -0,0 +1,7 @@
1
+ kryten_cli.py,sha256=n_8YWJ6EkMArTbKUs33-ywRfObmGtroZ7TsvqyDUxOE,27404
2
+ kryten_cli-2.1.1.dist-info/licenses/LICENSE,sha256=cDh70BrUr55sIU4Zb-SrutuYGG5MhsxsTg16W4JYVAc,1074
3
+ kryten_cli-2.1.1.dist-info/METADATA,sha256=BeSgwdCDQ4CA960Jgo6v_wvKxoCqeAZKqv55UuoYS-A,6900
4
+ kryten_cli-2.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
+ kryten_cli-2.1.1.dist-info/entry_points.txt,sha256=SABWYXWT2Ma113grN0EXoxX0wkQYnKWVW_XgVwzGc1M,42
6
+ kryten_cli-2.1.1.dist-info/top_level.txt,sha256=p6-vYVvHyL5r4ay2pYnkA6KyigDh-1pNglLoLNtKR40,11
7
+ kryten_cli-2.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ kryten = kryten_cli:run
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Kryten Robot Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ kryten_cli
kryten_cli.py ADDED
@@ -0,0 +1,731 @@
1
+ #!/usr/bin/env python3
2
+ """Kryten CLI - Send CyTube commands via NATS.
3
+
4
+ This command-line tool sends commands to a CyTube channel through NATS messaging.
5
+ It provides a simple interface to all outbound commands supported by the Kryten
6
+ bidirectional bridge.
7
+
8
+ Examples:
9
+ Send a chat message:
10
+ $ kryten say "Hello world"
11
+
12
+ Send a private message:
13
+ $ kryten pm UserName "Hi there!"
14
+
15
+ Add video to playlist:
16
+ $ kryten playlist add https://youtube.com/watch?v=xyz
17
+ $ kryten playlist addnext https://youtube.com/watch?v=abc
18
+
19
+ Delete from playlist:
20
+ $ kryten playlist del 5
21
+
22
+ Playlist management:
23
+ $ kryten playlist move 3 after 7
24
+ $ kryten playlist jump 5
25
+ $ kryten playlist clear
26
+ $ kryten playlist shuffle
27
+ $ kryten playlist settemp 5 true
28
+
29
+ Playback control:
30
+ $ kryten pause
31
+ $ kryten play
32
+ $ kryten seek 120.5
33
+
34
+ Moderation:
35
+ $ kryten kick UserName "Stop spamming"
36
+ $ kryten ban UserName "Banned for harassment"
37
+ $ kryten voteskip
38
+
39
+ Configuration:
40
+ The CLI reads NATS connection settings from config.json in the current
41
+ directory or from a path specified with --config.
42
+ """
43
+
44
+ import argparse
45
+ import asyncio
46
+ import json
47
+ import re
48
+ import sys
49
+ from pathlib import Path
50
+ from typing import Optional
51
+
52
+ from kryten import KrytenClient
53
+
54
+
55
+ class KrytenCLI:
56
+ """Command-line interface for Kryten CyTube commands."""
57
+
58
+ def __init__(self, config_path: str = "config.json"):
59
+ """Initialize CLI with configuration.
60
+
61
+ Args:
62
+ config_path: Path to configuration file.
63
+ """
64
+ self.config_path = Path(config_path)
65
+ self.config_dict = self._load_config()
66
+ self.client: Optional[KrytenClient] = None
67
+
68
+ # Extract channel and domain from config
69
+ channels = self.config_dict.get("channels", [])
70
+ if not channels:
71
+ # Legacy config format support
72
+ cytube = self.config_dict.get("cytube", {})
73
+ self.channel = cytube.get("channel", "")
74
+ self.domain = cytube.get("domain", "cytu.be")
75
+ else:
76
+ self.channel = channels[0]["channel"]
77
+ self.domain = channels[0].get("domain", "cytu.be")
78
+
79
+ def _load_config(self) -> dict:
80
+ """Load configuration from JSON file.
81
+
82
+ Returns:
83
+ Configuration dictionary.
84
+
85
+ Raises:
86
+ SystemExit: If config file not found or invalid.
87
+ """
88
+ if not self.config_path.exists():
89
+ print(f"Error: Configuration file not found: {self.config_path}", file=sys.stderr)
90
+ print("Create a config.json file with NATS and CyTube settings.", file=sys.stderr)
91
+ sys.exit(1)
92
+
93
+ try:
94
+ with self.config_path.open("r", encoding="utf-8") as f:
95
+ config = json.load(f)
96
+
97
+ # Ensure channels list exists for kryten-py
98
+ if "channels" not in config and "cytube" in config:
99
+ # Convert legacy format
100
+ cytube = config["cytube"]
101
+ config["channels"] = [{
102
+ "domain": cytube.get("domain", "cytu.be"),
103
+ "channel": cytube["channel"]
104
+ }]
105
+
106
+ return config
107
+ except json.JSONDecodeError as e:
108
+ print(f"Error: Invalid JSON in config file: {e}", file=sys.stderr)
109
+ sys.exit(1)
110
+
111
+ async def connect(self) -> None:
112
+ """Connect to NATS server using kryten-py client."""
113
+ try:
114
+ self.client = KrytenClient(self.config_dict)
115
+ await self.client.connect()
116
+ except OSError as e:
117
+ # Network/hostname errors
118
+ servers = self.config_dict.get("nats", {}).get("servers", [])
119
+ print(f"Error: Cannot connect to NATS server {servers}", file=sys.stderr)
120
+ print(f" {e}", file=sys.stderr)
121
+ print(" Check that:", file=sys.stderr)
122
+ print(" 1. NATS server is running", file=sys.stderr)
123
+ print(" 2. Hostname/IP is correct", file=sys.stderr)
124
+ print(" 3. Port is accessible", file=sys.stderr)
125
+ sys.exit(1)
126
+ except Exception as e:
127
+ print(f"Error: Failed to connect: {e}", file=sys.stderr)
128
+ sys.exit(1)
129
+
130
+ async def disconnect(self) -> None:
131
+ """Disconnect from NATS server."""
132
+ if self.client:
133
+ await self.client.disconnect()
134
+
135
+ def _parse_media_url(self, url: str) -> tuple[str, str]:
136
+ """Parse media URL to extract type and ID.
137
+
138
+ Args:
139
+ url: Media URL or ID
140
+
141
+ Returns:
142
+ Tuple of (media_type, media_id)
143
+ """
144
+ # YouTube patterns
145
+ yt_patterns = [
146
+ r'(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})',
147
+ r'^([a-zA-Z0-9_-]{11})$' # Direct ID
148
+ ]
149
+
150
+ for pattern in yt_patterns:
151
+ match = re.search(pattern, url)
152
+ if match:
153
+ return ("yt", match.group(1))
154
+
155
+ # Vimeo
156
+ vimeo_match = re.search(r'vimeo\.com/(\d+)', url)
157
+ if vimeo_match:
158
+ return ("vm", vimeo_match.group(1))
159
+
160
+ # Dailymotion
161
+ dm_match = re.search(r'dailymotion\.com/video/([a-zA-Z0-9]+)', url)
162
+ if dm_match:
163
+ return ("dm", dm_match.group(1))
164
+
165
+ # CyTube Custom Media JSON manifest (must end with .json)
166
+ if url.lower().endswith('.json') or '.json?' in url.lower():
167
+ return ("cm", url)
168
+
169
+ # Default: custom URL (for direct video files, custom embeds, etc.)
170
+ return ("cu", url)
171
+
172
+ # ========================================================================
173
+ # Chat Commands
174
+ # ========================================================================
175
+
176
+ async def cmd_say(self, message: str) -> None:
177
+ """Send a chat message.
178
+
179
+ Args:
180
+ message: Message text.
181
+ """
182
+ await self.client.send_chat(self.channel, message, domain=self.domain)
183
+ print(f"✓ Sent chat message to {self.channel}")
184
+
185
+ async def cmd_pm(self, username: str, message: str) -> None:
186
+ """Send a private message.
187
+
188
+ Args:
189
+ username: Target username.
190
+ message: Message text.
191
+ """
192
+ await self.client.send_pm(self.channel, username, message, domain=self.domain)
193
+ print(f"✓ Sent PM to {username} in {self.channel}")
194
+
195
+ # ========================================================================
196
+ # Playlist Commands
197
+ # ========================================================================
198
+
199
+ async def cmd_playlist_add(self, url: str) -> None:
200
+ """Add video to end of playlist.
201
+
202
+ Args:
203
+ url: Video URL or ID.
204
+ """
205
+ media_type, media_id = self._parse_media_url(url)
206
+ await self.client.add_media(
207
+ self.channel, media_type, media_id, position="end", domain=self.domain
208
+ )
209
+ print(f"✓ Added {media_type}:{media_id} to end of playlist in {self.channel}")
210
+
211
+ async def cmd_playlist_addnext(self, url: str) -> None:
212
+ """Add video to play next.
213
+
214
+ Args:
215
+ url: Video URL or ID.
216
+ """
217
+ media_type, media_id = self._parse_media_url(url)
218
+ await self.client.add_media(
219
+ self.channel, media_type, media_id, position="next", domain=self.domain
220
+ )
221
+ print(f"✓ Added {media_type}:{media_id} to play next in {self.channel}")
222
+
223
+ async def cmd_playlist_del(self, uid: str) -> None:
224
+ """Delete video from playlist.
225
+
226
+ Args:
227
+ uid: Video UID or position number (1-based).
228
+ """
229
+ uid_int = int(uid)
230
+
231
+ # If uid looks like a position (small number), fetch playlist and map position to UID
232
+ # CyTube UIDs are typically 4+ digits, positions are 1-based small numbers
233
+ if uid_int < 1000: # Assume this is a position, not a UID
234
+ bucket_name = f"cytube_{self.channel.lower()}_playlist"
235
+ try:
236
+ playlist = await self.client.kv_get(bucket_name, "items", default=None, parse_json=True)
237
+
238
+ if playlist is None or not isinstance(playlist, list):
239
+ print(f"Cannot resolve position {uid_int}: playlist not available", file=sys.stderr)
240
+ sys.exit(1)
241
+
242
+ if uid_int < 1 or uid_int > len(playlist):
243
+ print(f"Position {uid_int} out of range (playlist has {len(playlist)} items)", file=sys.stderr)
244
+ sys.exit(1)
245
+
246
+ # Get the actual UID from the playlist item
247
+ item = playlist[uid_int - 1] # Convert 1-based to 0-based
248
+ actual_uid = item.get("uid")
249
+
250
+ if actual_uid is None:
251
+ print(f"Could not find UID for position {uid_int}", file=sys.stderr)
252
+ sys.exit(1)
253
+
254
+ await self.client.delete_media(self.channel, actual_uid, domain=self.domain)
255
+ title = item.get("media", {}).get("title", "Unknown")
256
+ print(f"✓ Deleted position {uid_int} (UID {actual_uid}): {title}")
257
+
258
+ except Exception as e:
259
+ print(f"Error resolving position {uid_int}: {e}", file=sys.stderr)
260
+ sys.exit(1)
261
+ else:
262
+ # Large number, treat as direct UID
263
+ await self.client.delete_media(self.channel, uid_int, domain=self.domain)
264
+ print(f"✓ Deleted media UID {uid} from {self.channel}")
265
+
266
+ async def cmd_playlist_move(self, uid: str, after: str) -> None:
267
+ """Move video in playlist.
268
+
269
+ Args:
270
+ uid: Video UID or position to move.
271
+ after: UID or position to place after.
272
+ """
273
+ uid_int = int(uid)
274
+ after_int = int(after)
275
+
276
+ # Map positions to UIDs if needed (same logic as delete)
277
+ bucket_name = f"cytube_{self.channel.lower()}_playlist"
278
+
279
+ try:
280
+ playlist = await self.client.kv_get(bucket_name, "items", default=None, parse_json=True)
281
+
282
+ if playlist is None or not isinstance(playlist, list):
283
+ print(f"Cannot resolve positions: playlist not available", file=sys.stderr)
284
+ sys.exit(1)
285
+
286
+ # Resolve 'from' position to UID if it's a position number
287
+ actual_uid = uid_int
288
+ if uid_int < 1000: # Position number
289
+ if uid_int < 1 or uid_int > len(playlist):
290
+ print(f"Position {uid_int} out of range (playlist has {len(playlist)} items)", file=sys.stderr)
291
+ sys.exit(1)
292
+ actual_uid = playlist[uid_int - 1].get("uid")
293
+ if actual_uid is None:
294
+ print(f"Could not find UID for position {uid_int}", file=sys.stderr)
295
+ sys.exit(1)
296
+
297
+ # Resolve 'after' position to UID if it's a position number
298
+ actual_after = after_int
299
+ if after_int < 1000: # Position number
300
+ if after_int < 1 or after_int > len(playlist):
301
+ print(f"Position {after_int} out of range (playlist has {len(playlist)} items)", file=sys.stderr)
302
+ sys.exit(1)
303
+ actual_after = playlist[after_int - 1].get("uid")
304
+ if actual_after is None:
305
+ print(f"Could not find UID for position {after_int}", file=sys.stderr)
306
+ sys.exit(1)
307
+
308
+ await self.client.move_media(self.channel, actual_uid, actual_after, domain=self.domain)
309
+ print(f"✓ Moved media {uid} after {after} in {self.channel}")
310
+
311
+ except Exception as e:
312
+ print(f"Error moving media: {e}", file=sys.stderr)
313
+ sys.exit(1)
314
+
315
+ async def cmd_playlist_jump(self, uid: str) -> None:
316
+ """Jump to video in playlist.
317
+
318
+ Args:
319
+ uid: Video UID to jump to.
320
+ """
321
+ uid_int = int(uid)
322
+ await self.client.jump_to(self.channel, uid_int, domain=self.domain)
323
+ print(f"✓ Jumped to media {uid} in {self.channel}")
324
+
325
+ async def cmd_playlist_clear(self) -> None:
326
+ """Clear entire playlist."""
327
+ await self.client.clear_playlist(self.channel, domain=self.domain)
328
+ print(f"✓ Cleared playlist in {self.channel}")
329
+
330
+ async def cmd_playlist_shuffle(self) -> None:
331
+ """Shuffle playlist."""
332
+ await self.client.shuffle_playlist(self.channel, domain=self.domain)
333
+ print(f"✓ Shuffled playlist in {self.channel}")
334
+
335
+ async def cmd_playlist_settemp(self, uid: str, temp: bool) -> None:
336
+ """Set video temporary status.
337
+
338
+ Args:
339
+ uid: Video UID.
340
+ temp: Temporary status (true/false).
341
+ """
342
+ uid_int = int(uid)
343
+ await self.client.set_temp(self.channel, uid_int, temp, domain=self.domain)
344
+ print(f"✓ Set temp={temp} for media {uid} in {self.channel}")
345
+
346
+ # ========================================================================
347
+ # Playback Commands
348
+ # ========================================================================
349
+
350
+ async def cmd_pause(self) -> None:
351
+ """Pause playback."""
352
+ await self.client.pause(self.channel, domain=self.domain)
353
+ print(f"✓ Paused playback in {self.channel}")
354
+
355
+ async def cmd_play(self) -> None:
356
+ """Resume playback."""
357
+ await self.client.play(self.channel, domain=self.domain)
358
+ print(f"✓ Resumed playback in {self.channel}")
359
+
360
+ async def cmd_seek(self, time: float) -> None:
361
+ """Seek to timestamp.
362
+
363
+ Args:
364
+ time: Target time in seconds.
365
+ """
366
+ await self.client.seek(self.channel, time, domain=self.domain)
367
+ print(f"✓ Seeked to {time}s in {self.channel}")
368
+
369
+ # ========================================================================
370
+ # Moderation Commands
371
+ # ========================================================================
372
+
373
+ async def cmd_kick(self, username: str, reason: Optional[str] = None) -> None:
374
+ """Kick user from channel.
375
+
376
+ Args:
377
+ username: Username to kick.
378
+ reason: Optional kick reason.
379
+ """
380
+ await self.client.kick_user(self.channel, username, reason, domain=self.domain)
381
+ print(f"✓ Kicked {username} from {self.channel}")
382
+
383
+ async def cmd_ban(self, username: str, reason: Optional[str] = None) -> None:
384
+ """Ban user from channel.
385
+
386
+ Args:
387
+ username: Username to ban.
388
+ reason: Optional ban reason.
389
+ """
390
+ await self.client.ban_user(self.channel, username, reason, domain=self.domain)
391
+ print(f"✓ Banned {username} from {self.channel}")
392
+
393
+ async def cmd_voteskip(self) -> None:
394
+ """Vote to skip current video."""
395
+ await self.client.voteskip(self.channel, domain=self.domain)
396
+ print(f"✓ Voted to skip in {self.channel}")
397
+
398
+ # ========================================================================
399
+ # List Commands
400
+ # ========================================================================
401
+
402
+ async def cmd_list_queue(self) -> None:
403
+ """Display current playlist queue."""
404
+ try:
405
+ # Query state via unified command pattern
406
+ request = {
407
+ "service": "robot",
408
+ "command": "state.playlist"
409
+ }
410
+ response = await self.client.nats_request(
411
+ "kryten.robot.command",
412
+ request,
413
+ timeout=5.0
414
+ )
415
+
416
+ if not response.get("success"):
417
+ print(f"Error: {response.get('error', 'Unknown error')}")
418
+ print(f"Is Kryten-Robot running for channel '{self.channel}'?")
419
+ return
420
+
421
+ playlist = response.get("data", {}).get("playlist", [])
422
+
423
+ if not playlist:
424
+ print("Playlist is empty.")
425
+ return
426
+
427
+ print(f"\n{self.channel} Playlist ({len(playlist)} items):")
428
+ print("=" * 80)
429
+
430
+ for i, item in enumerate(playlist, 1):
431
+ media = item.get("media", {})
432
+ title = media.get("title", "Unknown")
433
+ duration = media.get("duration", "--:--")
434
+ media_type = media.get("type", "??")
435
+ uid = item.get("uid", "")
436
+ temp = " [TEMP]" if item.get("temp") else ""
437
+ queueby = item.get("queueby", "")
438
+
439
+ print(f"{i:3}. [{media_type}] {title}")
440
+ print(f" Duration: {duration} | UID: {uid}{temp}")
441
+ if queueby:
442
+ print(f" Queued by: {queueby}")
443
+ print()
444
+
445
+ except Exception as e:
446
+ print(f"Error retrieving playlist: {e}", file=sys.stderr)
447
+ sys.exit(1)
448
+
449
+ async def cmd_list_users(self) -> None:
450
+ """Display current user list."""
451
+ try:
452
+ # Query state via unified command pattern
453
+ request = {
454
+ "service": "robot",
455
+ "command": "state.userlist"
456
+ }
457
+ response = await self.client.nats_request(
458
+ "kryten.robot.command",
459
+ request,
460
+ timeout=5.0
461
+ )
462
+
463
+ if not response.get("success"):
464
+ print(f"Error: {response.get('error', 'Unknown error')}")
465
+ print(f"Is Kryten-Robot running for channel '{self.channel}'?")
466
+ return
467
+
468
+ users = response.get("data", {}).get("userlist", [])
469
+
470
+ if not users:
471
+ print("No users online.")
472
+ return
473
+
474
+ # Sort by rank (descending) then name
475
+ users_sorted = sorted(users, key=lambda u: (-u.get("rank", 0), u.get("name", "").lower()))
476
+
477
+ print(f"\n{self.channel} Users ({len(users)} online):")
478
+ print("=" * 80)
479
+
480
+ rank_names = {
481
+ 0: "Guest",
482
+ 1: "Registered",
483
+ 2: "Moderator",
484
+ 3: "Channel Admin",
485
+ 4: "Site Admin",
486
+ }
487
+
488
+ for user in users_sorted:
489
+ name = user.get("name", "Unknown")
490
+ rank = user.get("rank", 0)
491
+ rank_name = rank_names.get(rank, f"Rank {rank}")
492
+ afk = " [AFK]" if user.get("meta", {}).get("afk") else ""
493
+
494
+ print(f" [{rank}] {name} - {rank_name}{afk}")
495
+
496
+ except Exception as e:
497
+ print(f"Error retrieving user list: {e}", file=sys.stderr)
498
+ sys.exit(1)
499
+
500
+ async def cmd_list_emotes(self) -> None:
501
+ """Display channel emotes."""
502
+ try:
503
+ # Query state via unified command pattern
504
+ request = {
505
+ "service": "robot",
506
+ "command": "state.emotes"
507
+ }
508
+ response = await self.client.nats_request(
509
+ "kryten.robot.command",
510
+ request,
511
+ timeout=5.0
512
+ )
513
+
514
+ if not response.get("success"):
515
+ print(f"Error: {response.get('error', 'Unknown error')}")
516
+ print(f"Is Kryten-Robot running for channel '{self.channel}'?")
517
+ return
518
+
519
+ emotes = response.get("data", {}).get("emotes", [])
520
+
521
+ if not emotes:
522
+ print("No custom emotes configured.")
523
+ return
524
+
525
+ print(f"\n{self.channel} Custom Emotes ({len(emotes)} total):")
526
+ print("=" * 80)
527
+
528
+ for emote in emotes:
529
+ name = emote.get("name", "Unknown")
530
+ image = emote.get("image", "")
531
+
532
+ # Truncate long URLs for display
533
+ if len(image) > 60:
534
+ image_display = image[:57] + "..."
535
+ else:
536
+ image_display = image
537
+
538
+ print(f" {name:30} {image_display}")
539
+
540
+ except Exception as e:
541
+ print(f"Error retrieving emotes: {e}", file=sys.stderr)
542
+ sys.exit(1)
543
+
544
+
545
+ def create_parser() -> argparse.ArgumentParser:
546
+ """Create command-line argument parser.
547
+
548
+ Returns:
549
+ Configured ArgumentParser.
550
+ """
551
+ parser = argparse.ArgumentParser(
552
+ prog="kryten",
553
+ description="Send commands to CyTube channel via NATS",
554
+ epilog="See 'kryten <command> --help' for command-specific help."
555
+ )
556
+
557
+ parser.add_argument(
558
+ "--config",
559
+ default="config.json",
560
+ help="Path to configuration file (default: config.json)"
561
+ )
562
+
563
+ parser.add_argument(
564
+ "--channel",
565
+ help="Override channel from config"
566
+ )
567
+
568
+ subparsers = parser.add_subparsers(dest="command", help="Command to execute")
569
+
570
+ # Chat commands
571
+ say_parser = subparsers.add_parser("say", help="Send a chat message")
572
+ say_parser.add_argument("message", help="Message text")
573
+
574
+ pm_parser = subparsers.add_parser("pm", help="Send a private message")
575
+ pm_parser.add_argument("username", help="Target username")
576
+ pm_parser.add_argument("message", help="Message text")
577
+
578
+ # Playlist commands
579
+ playlist_parser = subparsers.add_parser("playlist", help="Playlist management")
580
+ playlist_subparsers = playlist_parser.add_subparsers(dest="playlist_cmd")
581
+
582
+ add_parser = playlist_subparsers.add_parser("add", help="Add video to end")
583
+ add_parser.add_argument("url", help="Video URL or ID")
584
+
585
+ addnext_parser = playlist_subparsers.add_parser("addnext", help="Add video to play next")
586
+ addnext_parser.add_argument("url", help="Video URL or ID")
587
+
588
+ del_parser = playlist_subparsers.add_parser("del", help="Delete video")
589
+ del_parser.add_argument("uid", help="Video UID or position")
590
+
591
+ move_parser = playlist_subparsers.add_parser("move", help="Move video")
592
+ move_parser.add_argument("uid", help="Video UID to move")
593
+ move_parser.add_argument("after", help="UID to place after")
594
+
595
+ jump_parser = playlist_subparsers.add_parser("jump", help="Jump to video")
596
+ jump_parser.add_argument("uid", help="Video UID")
597
+
598
+ playlist_subparsers.add_parser("clear", help="Clear playlist")
599
+ playlist_subparsers.add_parser("shuffle", help="Shuffle playlist")
600
+
601
+ settemp_parser = playlist_subparsers.add_parser("settemp", help="Set temp status")
602
+ settemp_parser.add_argument("uid", help="Video UID")
603
+ settemp_parser.add_argument("temp", choices=["true", "false"], help="Temporary status")
604
+
605
+ # Playback commands
606
+ subparsers.add_parser("pause", help="Pause playback")
607
+ subparsers.add_parser("play", help="Resume playback")
608
+
609
+ seek_parser = subparsers.add_parser("seek", help="Seek to timestamp")
610
+ seek_parser.add_argument("time", type=float, help="Time in seconds")
611
+
612
+ # Moderation commands
613
+ kick_parser = subparsers.add_parser("kick", help="Kick user")
614
+ kick_parser.add_argument("username", help="Username to kick")
615
+ kick_parser.add_argument("reason", nargs="?", help="Kick reason")
616
+
617
+ ban_parser = subparsers.add_parser("ban", help="Ban user")
618
+ ban_parser.add_argument("username", help="Username to ban")
619
+ ban_parser.add_argument("reason", nargs="?", help="Ban reason")
620
+
621
+ subparsers.add_parser("voteskip", help="Vote to skip current video")
622
+
623
+ # List commands
624
+ list_parser = subparsers.add_parser("list", help="List channel information")
625
+ list_subparsers = list_parser.add_subparsers(dest="list_cmd")
626
+
627
+ list_subparsers.add_parser("queue", help="Show current playlist")
628
+ list_subparsers.add_parser("users", help="Show online users")
629
+ list_subparsers.add_parser("emotes", help="Show channel emotes")
630
+
631
+ return parser
632
+
633
+
634
+ async def main() -> None:
635
+ """Main entry point for CLI."""
636
+ parser = create_parser()
637
+ args = parser.parse_args()
638
+
639
+ if not args.command:
640
+ parser.print_help()
641
+ sys.exit(1)
642
+
643
+ # Initialize CLI
644
+ cli = KrytenCLI(args.config)
645
+
646
+ # Override channel if specified
647
+ if args.channel:
648
+ cli.channel = args.channel
649
+
650
+ # Connect to NATS
651
+ await cli.connect()
652
+
653
+ try:
654
+ # Route to appropriate command handler
655
+ if args.command == "say":
656
+ await cli.cmd_say(args.message)
657
+
658
+ elif args.command == "pm":
659
+ await cli.cmd_pm(args.username, args.message)
660
+
661
+ elif args.command == "playlist":
662
+ if args.playlist_cmd == "add":
663
+ await cli.cmd_playlist_add(args.url)
664
+ elif args.playlist_cmd == "addnext":
665
+ await cli.cmd_playlist_addnext(args.url)
666
+ elif args.playlist_cmd == "del":
667
+ await cli.cmd_playlist_del(args.uid)
668
+ elif args.playlist_cmd == "move":
669
+ await cli.cmd_playlist_move(args.uid, args.after)
670
+ elif args.playlist_cmd == "jump":
671
+ await cli.cmd_playlist_jump(args.uid)
672
+ elif args.playlist_cmd == "clear":
673
+ await cli.cmd_playlist_clear()
674
+ elif args.playlist_cmd == "shuffle":
675
+ await cli.cmd_playlist_shuffle()
676
+ elif args.playlist_cmd == "settemp":
677
+ temp_bool = args.temp == "true"
678
+ await cli.cmd_playlist_settemp(args.uid, temp_bool)
679
+ else:
680
+ parser.parse_args(["playlist", "--help"])
681
+
682
+ elif args.command == "pause":
683
+ await cli.cmd_pause()
684
+
685
+ elif args.command == "play":
686
+ await cli.cmd_play()
687
+
688
+ elif args.command == "seek":
689
+ await cli.cmd_seek(args.time)
690
+
691
+ elif args.command == "kick":
692
+ await cli.cmd_kick(args.username, args.reason)
693
+
694
+ elif args.command == "ban":
695
+ await cli.cmd_ban(args.username, args.reason)
696
+
697
+ elif args.command == "voteskip":
698
+ await cli.cmd_voteskip()
699
+
700
+ elif args.command == "list":
701
+ if args.list_cmd == "queue":
702
+ await cli.cmd_list_queue()
703
+ elif args.list_cmd == "users":
704
+ await cli.cmd_list_users()
705
+ elif args.list_cmd == "emotes":
706
+ await cli.cmd_list_emotes()
707
+ else:
708
+ parser.parse_args(["list", "--help"])
709
+
710
+ else:
711
+ print(f"Error: Unknown command '{args.command}'", file=sys.stderr)
712
+ sys.exit(1)
713
+
714
+ finally:
715
+ await cli.disconnect()
716
+
717
+
718
+ def run() -> None:
719
+ """Entry point wrapper for setuptools."""
720
+ try:
721
+ asyncio.run(main())
722
+ except KeyboardInterrupt:
723
+ print("\nAborted.", file=sys.stderr)
724
+ sys.exit(130)
725
+ except Exception as e:
726
+ print(f"Error: {e}", file=sys.stderr)
727
+ sys.exit(1)
728
+
729
+
730
+ if __name__ == "__main__":
731
+ run()