pyauralis 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyauralis-0.1.0/.gitignore +6 -0
- pyauralis-0.1.0/PKG-INFO +122 -0
- pyauralis-0.1.0/README.md +103 -0
- pyauralis-0.1.0/examples/bot.py +75 -0
- pyauralis-0.1.0/pyproject.toml +30 -0
- pyauralis-0.1.0/src/auralis/__init__.py +53 -0
- pyauralis-0.1.0/src/auralis/client.py +266 -0
- pyauralis-0.1.0/src/auralis/enums.py +37 -0
- pyauralis-0.1.0/src/auralis/errors.py +35 -0
- pyauralis-0.1.0/src/auralis/models.py +90 -0
- pyauralis-0.1.0/src/auralis/player.py +305 -0
- pyauralis-0.1.0/src/auralis/rest.py +103 -0
- pyauralis-0.1.0/tests/test_queue.py +196 -0
pyauralis-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyauralis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official async Python SDK for the Auralis music platform
|
|
5
|
+
Project-URL: Homepage, https://github.com/auralis/auralis
|
|
6
|
+
Project-URL: Source, https://github.com/auralis/auralis/tree/main/sdks/python
|
|
7
|
+
Author: Auralis
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: audio,auralis,bot,discord,music
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: aiohttp>=3.9
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Provides-Extra: speedups
|
|
17
|
+
Requires-Dist: orjson>=3.9; extra == 'speedups'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# auralis
|
|
21
|
+
|
|
22
|
+
Official **async Python SDK** for the [Auralis](https://github.com/auralis/auralis) music platform.
|
|
23
|
+
One dependency (`aiohttp`), a tiny command surface, and the whole queue/skip/loop/shuffle
|
|
24
|
+
brain handled for you.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install auralis
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quickstart
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import asyncio
|
|
34
|
+
from auralis import Client, LoopMode
|
|
35
|
+
|
|
36
|
+
client = Client("http://localhost:8080", "YOUR-API-TOKEN")
|
|
37
|
+
|
|
38
|
+
@client.event
|
|
39
|
+
async def on_track_start(player, data):
|
|
40
|
+
print("now playing:", player.current.title)
|
|
41
|
+
|
|
42
|
+
@client.event
|
|
43
|
+
async def on_queue_end(player):
|
|
44
|
+
await player.disconnect()
|
|
45
|
+
|
|
46
|
+
async def main():
|
|
47
|
+
await client.start()
|
|
48
|
+
player = client.get_player(guild_id=123456789)
|
|
49
|
+
|
|
50
|
+
# --- wire your Discord library's voice events to the player (see below) ---
|
|
51
|
+
|
|
52
|
+
await player.play("ytsearch:never gonna give you up")
|
|
53
|
+
await player.add("https://youtu.be/dQw4w9WgXcQ")
|
|
54
|
+
player.set_loop(LoopMode.QUEUE)
|
|
55
|
+
await asyncio.Event().wait()
|
|
56
|
+
|
|
57
|
+
asyncio.run(main())
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Player commands
|
|
61
|
+
|
|
62
|
+
| Command | Does |
|
|
63
|
+
|---|---|
|
|
64
|
+
| `await player.play(query)` | resolve + enqueue, start if idle |
|
|
65
|
+
| `await player.add(query)` | enqueue only |
|
|
66
|
+
| `await player.skip()` | advance to next track |
|
|
67
|
+
| `await player.stop()` | stop + clear queue (stays connected) |
|
|
68
|
+
| `await player.pause()` / `resume()` | pause / resume |
|
|
69
|
+
| `await player.seek(ms)` | seek |
|
|
70
|
+
| `await player.set_volume(0..1000)` | volume (100 = unity) |
|
|
71
|
+
| `player.set_loop(LoopMode.OFF/TRACK/QUEUE)` | loop mode |
|
|
72
|
+
| `player.shuffle()` | shuffle the queue |
|
|
73
|
+
| `player.now_playing()` | current `Track` |
|
|
74
|
+
| `await player.set_filters("bass=g=8")` | raw ffmpeg `-af` filtergraph |
|
|
75
|
+
| `await player.disconnect()` | tear down the player |
|
|
76
|
+
|
|
77
|
+
`query` is a search string (`"ytsearch:…"`, a URL, a playlist URL) or a `Track` you
|
|
78
|
+
already loaded via `client.search(...)` / `client.load_tracks(...)`.
|
|
79
|
+
|
|
80
|
+
## Events
|
|
81
|
+
|
|
82
|
+
`@client.event` on an `async def on_<name>(...)`, or `client.add_listener("<name>", cb)`:
|
|
83
|
+
|
|
84
|
+
| Event | Args |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `on_ready()` | — |
|
|
87
|
+
| `on_track_start(player, data)` | track began |
|
|
88
|
+
| `on_track_end(player, data)` | track ended (`data["reason"]`) |
|
|
89
|
+
| `on_track_exception(player, data)` | playback error |
|
|
90
|
+
| `on_track_stuck(player, data)` | stalled |
|
|
91
|
+
| `on_player_update(player, data)` | live state (`player.state`) |
|
|
92
|
+
| `on_voice_closed(player, data)` | Discord voice closed |
|
|
93
|
+
| `on_queue_end(player)` | nothing left to play |
|
|
94
|
+
| `on_disconnect()` | websocket dropped (auto-reconnects) |
|
|
95
|
+
|
|
96
|
+
## Wiring Discord voice
|
|
97
|
+
|
|
98
|
+
The SDK never touches the Discord gateway — your bot owns that. Join a channel with
|
|
99
|
+
your library, then forward the two raw voice payloads:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# discord.py raw gateway events
|
|
103
|
+
@bot.event
|
|
104
|
+
async def on_socket_raw_receive(...):
|
|
105
|
+
... # or use a gateway hook
|
|
106
|
+
|
|
107
|
+
# when you get VOICE_SERVER_UPDATE for a guild:
|
|
108
|
+
client.get_player(guild_id).update_voice_server({"token": ..., "endpoint": ...})
|
|
109
|
+
|
|
110
|
+
# when you get VOICE_STATE_UPDATE for the bot:
|
|
111
|
+
client.get_player(guild_id).update_voice_state(
|
|
112
|
+
{"session_id": ..., "channel_id": ..., "user_id": bot_user_id}
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Once both arrive, the player connects and starts anything already queued.
|
|
117
|
+
|
|
118
|
+
## Notes
|
|
119
|
+
|
|
120
|
+
- The queue lives in this process (lost on bot restart).
|
|
121
|
+
- Node internals (node ids, endpoints, regions) are never exposed by this SDK.
|
|
122
|
+
- `pip install "auralis[speedups]"` pulls in `orjson` for faster JSON.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# auralis
|
|
2
|
+
|
|
3
|
+
Official **async Python SDK** for the [Auralis](https://github.com/auralis/auralis) music platform.
|
|
4
|
+
One dependency (`aiohttp`), a tiny command surface, and the whole queue/skip/loop/shuffle
|
|
5
|
+
brain handled for you.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install auralis
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import asyncio
|
|
15
|
+
from auralis import Client, LoopMode
|
|
16
|
+
|
|
17
|
+
client = Client("http://localhost:8080", "YOUR-API-TOKEN")
|
|
18
|
+
|
|
19
|
+
@client.event
|
|
20
|
+
async def on_track_start(player, data):
|
|
21
|
+
print("now playing:", player.current.title)
|
|
22
|
+
|
|
23
|
+
@client.event
|
|
24
|
+
async def on_queue_end(player):
|
|
25
|
+
await player.disconnect()
|
|
26
|
+
|
|
27
|
+
async def main():
|
|
28
|
+
await client.start()
|
|
29
|
+
player = client.get_player(guild_id=123456789)
|
|
30
|
+
|
|
31
|
+
# --- wire your Discord library's voice events to the player (see below) ---
|
|
32
|
+
|
|
33
|
+
await player.play("ytsearch:never gonna give you up")
|
|
34
|
+
await player.add("https://youtu.be/dQw4w9WgXcQ")
|
|
35
|
+
player.set_loop(LoopMode.QUEUE)
|
|
36
|
+
await asyncio.Event().wait()
|
|
37
|
+
|
|
38
|
+
asyncio.run(main())
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Player commands
|
|
42
|
+
|
|
43
|
+
| Command | Does |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `await player.play(query)` | resolve + enqueue, start if idle |
|
|
46
|
+
| `await player.add(query)` | enqueue only |
|
|
47
|
+
| `await player.skip()` | advance to next track |
|
|
48
|
+
| `await player.stop()` | stop + clear queue (stays connected) |
|
|
49
|
+
| `await player.pause()` / `resume()` | pause / resume |
|
|
50
|
+
| `await player.seek(ms)` | seek |
|
|
51
|
+
| `await player.set_volume(0..1000)` | volume (100 = unity) |
|
|
52
|
+
| `player.set_loop(LoopMode.OFF/TRACK/QUEUE)` | loop mode |
|
|
53
|
+
| `player.shuffle()` | shuffle the queue |
|
|
54
|
+
| `player.now_playing()` | current `Track` |
|
|
55
|
+
| `await player.set_filters("bass=g=8")` | raw ffmpeg `-af` filtergraph |
|
|
56
|
+
| `await player.disconnect()` | tear down the player |
|
|
57
|
+
|
|
58
|
+
`query` is a search string (`"ytsearch:…"`, a URL, a playlist URL) or a `Track` you
|
|
59
|
+
already loaded via `client.search(...)` / `client.load_tracks(...)`.
|
|
60
|
+
|
|
61
|
+
## Events
|
|
62
|
+
|
|
63
|
+
`@client.event` on an `async def on_<name>(...)`, or `client.add_listener("<name>", cb)`:
|
|
64
|
+
|
|
65
|
+
| Event | Args |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `on_ready()` | — |
|
|
68
|
+
| `on_track_start(player, data)` | track began |
|
|
69
|
+
| `on_track_end(player, data)` | track ended (`data["reason"]`) |
|
|
70
|
+
| `on_track_exception(player, data)` | playback error |
|
|
71
|
+
| `on_track_stuck(player, data)` | stalled |
|
|
72
|
+
| `on_player_update(player, data)` | live state (`player.state`) |
|
|
73
|
+
| `on_voice_closed(player, data)` | Discord voice closed |
|
|
74
|
+
| `on_queue_end(player)` | nothing left to play |
|
|
75
|
+
| `on_disconnect()` | websocket dropped (auto-reconnects) |
|
|
76
|
+
|
|
77
|
+
## Wiring Discord voice
|
|
78
|
+
|
|
79
|
+
The SDK never touches the Discord gateway — your bot owns that. Join a channel with
|
|
80
|
+
your library, then forward the two raw voice payloads:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
# discord.py raw gateway events
|
|
84
|
+
@bot.event
|
|
85
|
+
async def on_socket_raw_receive(...):
|
|
86
|
+
... # or use a gateway hook
|
|
87
|
+
|
|
88
|
+
# when you get VOICE_SERVER_UPDATE for a guild:
|
|
89
|
+
client.get_player(guild_id).update_voice_server({"token": ..., "endpoint": ...})
|
|
90
|
+
|
|
91
|
+
# when you get VOICE_STATE_UPDATE for the bot:
|
|
92
|
+
client.get_player(guild_id).update_voice_state(
|
|
93
|
+
{"session_id": ..., "channel_id": ..., "user_id": bot_user_id}
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Once both arrive, the player connects and starts anything already queued.
|
|
98
|
+
|
|
99
|
+
## Notes
|
|
100
|
+
|
|
101
|
+
- The queue lives in this process (lost on bot restart).
|
|
102
|
+
- Node internals (node ids, endpoints, regions) are never exposed by this SDK.
|
|
103
|
+
- `pip install "auralis[speedups]"` pulls in `orjson` for faster JSON.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Minimal discord.py music bot on top of the Auralis SDK.
|
|
2
|
+
|
|
3
|
+
pip install auralis discord.py
|
|
4
|
+
AURALIS_URL=http://localhost:8080 AURALIS_TOKEN=... DISCORD_TOKEN=... python bot.py
|
|
5
|
+
|
|
6
|
+
Commands: !play <query>, !skip, !stop, !queue
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
import discord
|
|
12
|
+
from discord.ext import commands
|
|
13
|
+
|
|
14
|
+
from auralis import Client
|
|
15
|
+
|
|
16
|
+
intents = discord.Intents.default()
|
|
17
|
+
intents.message_content = True
|
|
18
|
+
bot = commands.Bot(command_prefix="!", intents=intents)
|
|
19
|
+
auralis = Client(os.environ["AURALIS_URL"], os.environ["AURALIS_TOKEN"])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@bot.event
|
|
23
|
+
async def on_ready():
|
|
24
|
+
await auralis.start()
|
|
25
|
+
print(f"logged in as {bot.user}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Forward Discord's raw voice gateway events straight to the matching player.
|
|
29
|
+
@bot.event
|
|
30
|
+
async def on_socket_response(payload):
|
|
31
|
+
op, data = payload.get("t"), payload.get("d") or {}
|
|
32
|
+
if op == "VOICE_SERVER_UPDATE":
|
|
33
|
+
auralis.get_player(int(data["guild_id"])).update_voice_server(data)
|
|
34
|
+
elif op == "VOICE_STATE_UPDATE" and int(data["user_id"]) == bot.user.id:
|
|
35
|
+
auralis.get_player(int(data["guild_id"])).update_voice_state(data)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@auralis.event
|
|
39
|
+
async def on_track_start(player, _data):
|
|
40
|
+
print("now playing:", player.current.title)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@bot.command()
|
|
44
|
+
async def play(ctx, *, query: str):
|
|
45
|
+
if not ctx.author.voice:
|
|
46
|
+
return await ctx.send("join a voice channel first")
|
|
47
|
+
# Tell Discord to connect the bot — this triggers the voice events above.
|
|
48
|
+
await ctx.guild.change_voice_state(channel=ctx.author.voice.channel)
|
|
49
|
+
player = auralis.get_player(ctx.guild.id)
|
|
50
|
+
tracks = await player.play(query, requester=ctx.author.id)
|
|
51
|
+
await ctx.send(f"queued **{tracks[0].title}**")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@bot.command()
|
|
55
|
+
async def skip(ctx):
|
|
56
|
+
await auralis.get_player(ctx.guild.id).skip()
|
|
57
|
+
await ctx.send("⏭️ skipped")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@bot.command()
|
|
61
|
+
async def stop(ctx):
|
|
62
|
+
await auralis.get_player(ctx.guild.id).disconnect()
|
|
63
|
+
await ctx.guild.change_voice_state(channel=None)
|
|
64
|
+
await ctx.send("⏹️ stopped")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@bot.command()
|
|
68
|
+
async def queue(ctx):
|
|
69
|
+
player = auralis.get_player(ctx.guild.id)
|
|
70
|
+
lines = [f"**now:** {player.current.title}"] if player.current else []
|
|
71
|
+
lines += [f"{i + 1}. {t.title}" for i, t in enumerate(player.queue[:10])]
|
|
72
|
+
await ctx.send("\n".join(lines) or "queue is empty")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
bot.run(os.environ["DISCORD_TOKEN"])
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyauralis"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official async Python SDK for the Auralis music platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Auralis" }]
|
|
13
|
+
keywords = ["auralis", "discord", "music", "audio", "bot"]
|
|
14
|
+
classifiers = ["License :: OSI Approved :: MIT License"]
|
|
15
|
+
dependencies = ["aiohttp>=3.9"]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
speedups = ["orjson>=3.9"]
|
|
19
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/auralis/auralis"
|
|
23
|
+
Source = "https://github.com/auralis/auralis/tree/main/sdks/python"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/auralis"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
asyncio_mode = "auto"
|
|
30
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Auralis — the official async Python SDK for the Auralis music platform.
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from auralis import Client
|
|
5
|
+
|
|
6
|
+
async def main():
|
|
7
|
+
client = Client("http://localhost:8080", "my-token")
|
|
8
|
+
await client.start()
|
|
9
|
+
|
|
10
|
+
@client.event
|
|
11
|
+
async def on_track_start(player, data):
|
|
12
|
+
print("now playing:", player.current.title)
|
|
13
|
+
|
|
14
|
+
player = client.get_player(guild_id)
|
|
15
|
+
# ... wire player.update_voice_server / update_voice_state from your bot ...
|
|
16
|
+
await player.play("ytsearch:never gonna give you up")
|
|
17
|
+
|
|
18
|
+
The queue, skip, loop, and shuffle all live in this library — the backend only
|
|
19
|
+
plays what it is told. See ``Player`` for the full command surface.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .client import Client
|
|
23
|
+
from .enums import EndReason, LoadType, LoopMode
|
|
24
|
+
from .errors import (
|
|
25
|
+
APIError,
|
|
26
|
+
AuralisError,
|
|
27
|
+
AuthError,
|
|
28
|
+
LoadError,
|
|
29
|
+
NotConnected,
|
|
30
|
+
RateLimited,
|
|
31
|
+
)
|
|
32
|
+
from .models import LoadResult, PlayerState, Track
|
|
33
|
+
from .player import Player
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Client",
|
|
39
|
+
"Player",
|
|
40
|
+
"Track",
|
|
41
|
+
"LoadResult",
|
|
42
|
+
"PlayerState",
|
|
43
|
+
"LoopMode",
|
|
44
|
+
"LoadType",
|
|
45
|
+
"EndReason",
|
|
46
|
+
"AuralisError",
|
|
47
|
+
"AuthError",
|
|
48
|
+
"RateLimited",
|
|
49
|
+
"APIError",
|
|
50
|
+
"NotConnected",
|
|
51
|
+
"LoadError",
|
|
52
|
+
"__version__",
|
|
53
|
+
]
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""The top-level client: owns the HTTP session, the WebSocket event loop, the
|
|
2
|
+
per-guild players, and event dispatch."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import contextlib
|
|
8
|
+
import logging
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Awaitable, Callable
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
|
|
15
|
+
from .enums import EndReason
|
|
16
|
+
from .models import LoadResult, PlayerState, Track
|
|
17
|
+
from .player import Player
|
|
18
|
+
from .rest import REST
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("auralis")
|
|
21
|
+
|
|
22
|
+
Listener = Callable[..., Any]
|
|
23
|
+
|
|
24
|
+
# WS op -> the event name we dispatch to user listeners.
|
|
25
|
+
_EVENT_OPS = {
|
|
26
|
+
"TRACK_START": "track_start",
|
|
27
|
+
"TRACK_END": "track_end",
|
|
28
|
+
"TRACK_EXCEPTION": "track_exception",
|
|
29
|
+
"TRACK_STUCK": "track_stuck",
|
|
30
|
+
"PLAYER_UPDATE": "player_update",
|
|
31
|
+
"VOICE_CLOSED": "voice_closed",
|
|
32
|
+
"STATS": "stats",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Client:
|
|
37
|
+
"""Connect once, then drive playback through :meth:`get_player`.
|
|
38
|
+
|
|
39
|
+
``base_url`` is the control-plane HTTP origin (e.g. ``http://localhost:8080``);
|
|
40
|
+
the WebSocket URL is derived from it.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
base_url: str,
|
|
46
|
+
token: str,
|
|
47
|
+
*,
|
|
48
|
+
reconnect: bool = True,
|
|
49
|
+
session: aiohttp.ClientSession | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.base_url = base_url.rstrip("/")
|
|
52
|
+
self._token = token
|
|
53
|
+
self._reconnect = reconnect
|
|
54
|
+
self._session = session
|
|
55
|
+
self._owns_session = session is None
|
|
56
|
+
self._rest: REST | None = None # set in start()
|
|
57
|
+
|
|
58
|
+
self._players: dict[int, Player] = {}
|
|
59
|
+
self._listeners: dict[str, list[Listener]] = {}
|
|
60
|
+
self._tasks: set[asyncio.Task] = set()
|
|
61
|
+
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
|
62
|
+
self._ws_task: asyncio.Task | None = None
|
|
63
|
+
self._session_id: str | None = None
|
|
64
|
+
self._closed = False
|
|
65
|
+
self._has_connected = False
|
|
66
|
+
self._heartbeat_sent_at = 0.0
|
|
67
|
+
|
|
68
|
+
#: Last measured heartbeat round-trip latency in seconds (-1 until first ack).
|
|
69
|
+
self.latency: float = -1.0
|
|
70
|
+
#: Most recent STATS frame payload from the node, or ``None``.
|
|
71
|
+
self.last_stats: dict[str, Any] | None = None
|
|
72
|
+
|
|
73
|
+
# --- lifecycle --------------------------------------------------------
|
|
74
|
+
async def start(self) -> None:
|
|
75
|
+
"""Open the HTTP session and the event WebSocket."""
|
|
76
|
+
if self._session is None:
|
|
77
|
+
self._session = aiohttp.ClientSession()
|
|
78
|
+
self._rest = REST(self.base_url, self._token, self._session)
|
|
79
|
+
self._ws_task = self._schedule(self._ws_loop())
|
|
80
|
+
|
|
81
|
+
async def close(self) -> None:
|
|
82
|
+
self._closed = True
|
|
83
|
+
if self._ws is not None:
|
|
84
|
+
await self._ws.close()
|
|
85
|
+
if self._ws_task is not None:
|
|
86
|
+
self._ws_task.cancel()
|
|
87
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
88
|
+
await self._ws_task
|
|
89
|
+
for t in list(self._tasks):
|
|
90
|
+
t.cancel()
|
|
91
|
+
if self._owns_session and self._session is not None:
|
|
92
|
+
await self._session.close()
|
|
93
|
+
|
|
94
|
+
async def __aenter__(self) -> "Client":
|
|
95
|
+
await self.start()
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
99
|
+
await self.close()
|
|
100
|
+
|
|
101
|
+
# --- players ----------------------------------------------------------
|
|
102
|
+
def get_player(self, guild_id: int) -> Player:
|
|
103
|
+
"""Get (creating if needed) the player for a guild."""
|
|
104
|
+
p = self._players.get(guild_id)
|
|
105
|
+
if p is None:
|
|
106
|
+
p = Player(self, guild_id)
|
|
107
|
+
self._players[guild_id] = p
|
|
108
|
+
return p
|
|
109
|
+
|
|
110
|
+
def players(self) -> list[Player]:
|
|
111
|
+
return list(self._players.values())
|
|
112
|
+
|
|
113
|
+
def _drop_player(self, guild_id: int) -> None:
|
|
114
|
+
self._players.pop(guild_id, None)
|
|
115
|
+
|
|
116
|
+
# --- metadata convenience --------------------------------------------
|
|
117
|
+
async def load_tracks(self, identifier: str) -> LoadResult:
|
|
118
|
+
assert self._rest is not None, "call start() first"
|
|
119
|
+
return await self._rest.load_tracks(identifier)
|
|
120
|
+
|
|
121
|
+
async def search(self, query: str) -> list[Track]:
|
|
122
|
+
"""``ytsearch:``-prefix a bare query and return the hits (empty on miss)."""
|
|
123
|
+
ident = query if ":" in query.split(" ", 1)[0] else f"ytsearch:{query}"
|
|
124
|
+
return (await self.load_tracks(ident)).tracks
|
|
125
|
+
|
|
126
|
+
# --- events -----------------------------------------------------------
|
|
127
|
+
def event(self, func: Listener) -> Listener:
|
|
128
|
+
"""Decorator: ``@client.event`` on ``async def on_track_start(...)``."""
|
|
129
|
+
name = func.__name__[3:] if func.__name__.startswith("on_") else func.__name__
|
|
130
|
+
self.add_listener(name, func)
|
|
131
|
+
return func
|
|
132
|
+
|
|
133
|
+
def add_listener(self, name: str, func: Listener) -> None:
|
|
134
|
+
self._listeners.setdefault(name, []).append(func)
|
|
135
|
+
|
|
136
|
+
def _dispatch(self, name: str, *args: Any) -> None:
|
|
137
|
+
for cb in self._listeners.get(name, ()):
|
|
138
|
+
res = cb(*args)
|
|
139
|
+
if asyncio.iscoroutine(res):
|
|
140
|
+
self._schedule(res)
|
|
141
|
+
|
|
142
|
+
# --- task helper ------------------------------------------------------
|
|
143
|
+
def _schedule(self, coro: Awaitable[Any]) -> asyncio.Task:
|
|
144
|
+
task = asyncio.ensure_future(coro)
|
|
145
|
+
self._tasks.add(task)
|
|
146
|
+
task.add_done_callback(self._on_task_done)
|
|
147
|
+
return task
|
|
148
|
+
|
|
149
|
+
def _on_task_done(self, task: asyncio.Task) -> None:
|
|
150
|
+
# Surface (not swallow) background-task failures — e.g. a raising listener.
|
|
151
|
+
self._tasks.discard(task)
|
|
152
|
+
if not task.cancelled() and task.exception() is not None:
|
|
153
|
+
log.warning("background task error: %s", task.exception())
|
|
154
|
+
|
|
155
|
+
# --- websocket --------------------------------------------------------
|
|
156
|
+
@property
|
|
157
|
+
def _ws_url(self) -> str:
|
|
158
|
+
ws = self.base_url.replace("https://", "wss://").replace("http://", "ws://")
|
|
159
|
+
return f"{ws}/v1/ws"
|
|
160
|
+
|
|
161
|
+
async def _ws_loop(self) -> None:
|
|
162
|
+
backoff = 1.0
|
|
163
|
+
while not self._closed:
|
|
164
|
+
try:
|
|
165
|
+
assert self._session is not None
|
|
166
|
+
async with self._session.ws_connect(
|
|
167
|
+
self._ws_url, headers={"Authorization": f"Bearer {self._token}"}
|
|
168
|
+
) as ws:
|
|
169
|
+
self._ws = ws
|
|
170
|
+
backoff = 1.0
|
|
171
|
+
await self._subscribe_all()
|
|
172
|
+
async for msg in ws:
|
|
173
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
174
|
+
# Isolate handler failures: a transient REST error while
|
|
175
|
+
# auto-advancing must not tear down the socket. Log and
|
|
176
|
+
# route to an "error" listener, then keep reading.
|
|
177
|
+
try:
|
|
178
|
+
await self._handle_frame(msg.json())
|
|
179
|
+
except Exception as exc: # noqa: BLE001
|
|
180
|
+
log.warning("frame handler error: %s", exc)
|
|
181
|
+
self._dispatch("error", exc)
|
|
182
|
+
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
183
|
+
break
|
|
184
|
+
except asyncio.CancelledError:
|
|
185
|
+
raise
|
|
186
|
+
except Exception as exc: # noqa: BLE001 — log and retry; never crash the loop
|
|
187
|
+
log.warning("websocket error: %s", exc)
|
|
188
|
+
finally:
|
|
189
|
+
self._ws = None
|
|
190
|
+
if self._closed or not self._reconnect:
|
|
191
|
+
return
|
|
192
|
+
self._dispatch("disconnect")
|
|
193
|
+
# Backoff with ±25% jitter so many clients don't reconnect in lockstep.
|
|
194
|
+
await asyncio.sleep(backoff * (0.75 + random.random() * 0.5))
|
|
195
|
+
backoff = min(backoff * 2, 30.0)
|
|
196
|
+
|
|
197
|
+
async def _resync_players(self) -> None:
|
|
198
|
+
"""After a reconnect, pull each player's authoritative state from the node
|
|
199
|
+
so position/volume/paused are accurate again. Per-player failures ignored."""
|
|
200
|
+
assert self._rest is not None
|
|
201
|
+
for p in self.players():
|
|
202
|
+
try:
|
|
203
|
+
p.state = await self._rest.get_state(p.guild_id)
|
|
204
|
+
except Exception: # noqa: BLE001 — node may have dropped it during the gap
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
async def _subscribe_all(self) -> None:
|
|
208
|
+
if self._ws is not None and self._players:
|
|
209
|
+
await self._ws.send_json(
|
|
210
|
+
{"op": "SUBSCRIBE", "d": {"guilds": [str(g) for g in self._players]}}
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
async def _send(self, payload: dict) -> None:
|
|
214
|
+
if self._ws is not None:
|
|
215
|
+
await self._ws.send_json(payload)
|
|
216
|
+
|
|
217
|
+
async def _handle_frame(self, frame: dict) -> None:
|
|
218
|
+
op = frame.get("op")
|
|
219
|
+
d = frame.get("d", {}) or {}
|
|
220
|
+
|
|
221
|
+
if op == "HELLO":
|
|
222
|
+
interval = d.get("heartbeat_interval_ms", 30000) / 1000.0
|
|
223
|
+
self._schedule(self._heartbeat(interval))
|
|
224
|
+
return
|
|
225
|
+
if op == "READY":
|
|
226
|
+
resumed = self._has_connected
|
|
227
|
+
self._session_id = d.get("session_id")
|
|
228
|
+
self._has_connected = True
|
|
229
|
+
await self._subscribe_all()
|
|
230
|
+
if resumed:
|
|
231
|
+
# Reconnect: resync each player's live state from the node so
|
|
232
|
+
# callers see accurate position/volume/paused (a practical resume).
|
|
233
|
+
await self._resync_players()
|
|
234
|
+
self._dispatch("resumed")
|
|
235
|
+
else:
|
|
236
|
+
self._dispatch("ready")
|
|
237
|
+
return
|
|
238
|
+
if op == "HEARTBEAT_ACK":
|
|
239
|
+
if self._heartbeat_sent_at:
|
|
240
|
+
self.latency = time.monotonic() - self._heartbeat_sent_at
|
|
241
|
+
return
|
|
242
|
+
if op == "STATS":
|
|
243
|
+
self.last_stats = d
|
|
244
|
+
|
|
245
|
+
guild_id = int(d["guild_id"]) if d.get("guild_id") else None
|
|
246
|
+
player = self._players.get(guild_id) if guild_id is not None else None
|
|
247
|
+
|
|
248
|
+
if op == "TRACK_END" and player is not None:
|
|
249
|
+
reason = EndReason(d.get("reason", "FINISHED"))
|
|
250
|
+
# Drive auto-advance BEFORE notifying the user so handlers see the new state.
|
|
251
|
+
await player._on_track_end(reason.advances)
|
|
252
|
+
elif op == "PLAYER_UPDATE" and player is not None:
|
|
253
|
+
player.state = PlayerState.from_dict(d.get("state", {}))
|
|
254
|
+
|
|
255
|
+
event_name = _EVENT_OPS.get(op or "")
|
|
256
|
+
if event_name is not None:
|
|
257
|
+
self._dispatch(event_name, player or guild_id, d)
|
|
258
|
+
|
|
259
|
+
async def _heartbeat(self, interval: float) -> None:
|
|
260
|
+
seq = 0
|
|
261
|
+
while self._ws is not None and not self._closed:
|
|
262
|
+
seq += 1
|
|
263
|
+
self._heartbeat_sent_at = time.monotonic()
|
|
264
|
+
with contextlib.suppress(Exception):
|
|
265
|
+
await self._send({"op": "HEARTBEAT", "d": {"seq": seq}})
|
|
266
|
+
await asyncio.sleep(interval)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Enums mirroring the control-plane wire vocabulary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LoopMode(str, Enum):
|
|
9
|
+
"""How the queue advances when a track finishes."""
|
|
10
|
+
|
|
11
|
+
OFF = "off"
|
|
12
|
+
TRACK = "track" # replay the current track forever
|
|
13
|
+
QUEUE = "queue" # loop the whole queue
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LoadType(str, Enum):
|
|
17
|
+
"""`load_type` returned by ``GET /v1/loadtracks``."""
|
|
18
|
+
|
|
19
|
+
TRACK = "track"
|
|
20
|
+
PLAYLIST = "playlist"
|
|
21
|
+
SEARCH = "search"
|
|
22
|
+
EMPTY = "empty"
|
|
23
|
+
ERROR = "error"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class EndReason(str, Enum):
|
|
27
|
+
"""Why a track ended. Only FINISHED / LOAD_FAILED advance the queue."""
|
|
28
|
+
|
|
29
|
+
FINISHED = "FINISHED"
|
|
30
|
+
LOAD_FAILED = "LOAD_FAILED"
|
|
31
|
+
STOPPED = "STOPPED"
|
|
32
|
+
REPLACED = "REPLACED"
|
|
33
|
+
CLEANUP = "CLEANUP"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def advances(self) -> bool:
|
|
37
|
+
return self in (EndReason.FINISHED, EndReason.LOAD_FAILED)
|