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,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()
|