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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
@@ -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)