Skip to content
This repository was archived by the owner on Apr 5, 2025. It is now read-only.

Commit 12dde9f

Browse files
authored
Fix/player state (#325)
* Add logic to check for player disconnects. * Bump version to 3.5.0 * Update docs. * Add node disconnected event and payloads. * Add switch_node method to Player. * Update pyproject.toml * Update __init__.py
1 parent 4adb979 commit 12dde9f

File tree

10 files changed

+217
-16
lines changed

10 files changed

+217
-16
lines changed

docs/conf.py

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import re
2222
import sys
2323

24+
2425
sys.path.insert(0, os.path.abspath("."))
2526
sys.path.insert(0, os.path.abspath(".."))
2627
sys.path.append(os.path.abspath("extensions"))

docs/extensions/attributetable.py

+10-8
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import importlib
44
import inspect
55
import re
6-
from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Sequence, Tuple
6+
from collections.abc import Sequence
7+
from typing import TYPE_CHECKING, NamedTuple
78

89
from docutils import nodes
910
from sphinx import addnodes
@@ -13,6 +14,7 @@
1314
from sphinx.util.docutils import SphinxDirective
1415
from sphinx.util.typing import OptionSpec
1516

17+
1618
if TYPE_CHECKING:
1719
from .builder import DPYHTML5Translator
1820

@@ -96,7 +98,7 @@ class PyAttributeTable(SphinxDirective):
9698
final_argument_whitespace = False
9799
option_spec: OptionSpec = {}
98100

99-
def parse_name(self, content: str) -> Tuple[str, str]:
101+
def parse_name(self, content: str) -> tuple[str, str]:
100102
match = _name_parser_regex.match(content)
101103
if match is None:
102104
raise RuntimeError(f"content {content} somehow doesn't match regex in {self.env.docname}.")
@@ -112,7 +114,7 @@ def parse_name(self, content: str) -> Tuple[str, str]:
112114

113115
return modulename, name
114116

115-
def run(self) -> List[attributetableplaceholder]:
117+
def run(self) -> list[attributetableplaceholder]:
116118
"""If you're curious on the HTML this is meant to generate:
117119
118120
<div class="py-attribute-table">
@@ -149,7 +151,7 @@ def run(self) -> List[attributetableplaceholder]:
149151
return [node]
150152

151153

152-
def build_lookup_table(env: BuildEnvironment) -> Dict[str, List[str]]:
154+
def build_lookup_table(env: BuildEnvironment) -> dict[str, list[str]]:
153155
# Given an environment, load up a lookup table of
154156
# full-class-name: objects
155157
result = {}
@@ -178,7 +180,7 @@ def build_lookup_table(env: BuildEnvironment) -> Dict[str, List[str]]:
178180
class TableElement(NamedTuple):
179181
fullname: str
180182
label: str
181-
badge: Optional[attributetablebadge]
183+
badge: attributetablebadge | None
182184

183185

184186
def process_attributetable(app: Sphinx, doctree: nodes.Node, fromdocname: str) -> None:
@@ -203,12 +205,12 @@ def process_attributetable(app: Sphinx, doctree: nodes.Node, fromdocname: str) -
203205

204206

205207
def get_class_results(
206-
lookup: Dict[str, List[str]], modulename: str, name: str, fullname: str
207-
) -> Dict[str, List[TableElement]]:
208+
lookup: dict[str, list[str]], modulename: str, name: str, fullname: str
209+
) -> dict[str, list[TableElement]]:
208210
module = importlib.import_module(modulename)
209211
cls = getattr(module, name)
210212

211-
groups: Dict[str, List[TableElement]] = {
213+
groups: dict[str, list[TableElement]] = {
212214
_("Attributes"): [],
213215
_("Methods"): [],
214216
}

docs/wavelink.rst

+25
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@ An event listener in a cog.
3434
This event can be called many times throughout your bots lifetime, as it will be called when Wavelink successfully
3535
reconnects to your node in the event of a disconnect.
3636

37+
.. function:: on_wavelink_node_disconnected(payload: wavelink.NodeDisconnectedEventPayload)
38+
39+
Called when a Node has disconnected/lost connection to wavelink. **This is NOT** the same as a node being closed.
40+
This event will however be called directly before the :func:`on_wavelink_node_closed` event.
41+
42+
The default behaviour is for wavelink to attempt to reconnect a disconnected Node. This event can change that
43+
behaviour. If you want to close this node completely see: :meth:`Node.close`
44+
45+
This event can be used to manage currrently connected players to this Node.
46+
See: :meth:`Player.switch_node`
47+
48+
.. versionadded:: 3.5.0
49+
3750
.. function:: on_wavelink_stats_update(payload: wavelink.StatsEventPayload)
3851

3952
Called when the ``stats`` OP is received by Lavalink.
@@ -128,6 +141,11 @@ Types
128141
129142
tracks: wavelink.Search = await wavelink.Playable.search("Ocean Drive")
130143
144+
.. attributetable:: PlayerBasicState
145+
146+
.. autoclass:: PlayerBasicState
147+
148+
131149

132150
Payloads
133151
---------
@@ -136,6 +154,11 @@ Payloads
136154
.. autoclass:: NodeReadyEventPayload
137155
:members:
138156

157+
.. attributetable:: NodeDisconnectedEventPayload
158+
159+
.. autoclass:: NodeDisconnectedEventPayload
160+
:members:
161+
139162
.. attributetable:: TrackStartEventPayload
140163

141164
.. autoclass:: TrackStartEventPayload
@@ -442,6 +465,8 @@ Exceptions
442465
Exception raised when a :class:`Node` is tried to be retrieved from the
443466
:class:`Pool` without existing, or the ``Pool`` is empty.
444467

468+
This exception is also raised when providing an invalid node to :meth:`Player.switch_node`.
469+
445470
.. py:exception:: LavalinkException
446471
447472
Exception raised when Lavalink returns an invalid response.

pyproject.toml

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "wavelink"
7-
version = "3.4.2"
7+
version = "3.5.0"
8+
89
authors = [
910
{ name="PythonistaGuild, EvieePy", email="[email protected]" },
1011
]

wavelink/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,7 @@
2626
__author__ = "PythonistaGuild, EvieePy"
2727
__license__ = "MIT"
2828
__copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy"
29-
__version__ = "3.4.2"
30-
29+
__version__ = "3.5.0"
3130

3231
from .enums import *
3332
from .exceptions import *
@@ -38,4 +37,5 @@
3837
from .player import Player as Player
3938
from .queue import *
4039
from .tracks import *
40+
from .types.state import PlayerBasicState as PlayerBasicState
4141
from .utils import ExtrasNamespace as ExtrasNamespace

wavelink/exceptions.py

+2
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ class AuthorizationFailedException(WavelinkException):
8282
class InvalidNodeException(WavelinkException):
8383
"""Exception raised when a :class:`Node` is tried to be retrieved from the
8484
:class:`Pool` without existing, or the ``Pool`` is empty.
85+
86+
This exception is also raised when providing an invalid node to :meth:`~wavelink.Player.switch_node`.
8587
"""
8688

8789

wavelink/payloads.py

+14
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"PlayerUpdateEventPayload",
5454
"StatsEventPayload",
5555
"NodeReadyEventPayload",
56+
"NodeDisconnectedEventPayload",
5657
"StatsEventMemory",
5758
"StatsEventCPU",
5859
"StatsEventFrames",
@@ -87,6 +88,19 @@ def __init__(self, node: Node, resumed: bool, session_id: str) -> None:
8788
self.session_id = session_id
8889

8990

91+
class NodeDisconnectedEventPayload:
92+
"""Payload received in the :func:`on_wavelink_node_disconnected` event.
93+
94+
Attributes
95+
----------
96+
node: :class:`~wavelink.Node`
97+
The node that has disconnected.
98+
"""
99+
100+
def __init__(self, node: Node) -> None:
101+
self.node = node
102+
103+
90104
class TrackStartEventPayload:
91105
"""Payload received in the :func:`on_wavelink_track_start` event.
92106

wavelink/player.py

+116-5
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from .exceptions import (
4242
ChannelTimeoutException,
4343
InvalidChannelStateException,
44+
InvalidNodeException,
4445
LavalinkException,
4546
LavalinkLoadException,
4647
QueueEmpty,
@@ -73,7 +74,7 @@
7374
TrackStartEventPayload,
7475
)
7576
from .types.request import Request as RequestPayload
76-
from .types.state import PlayerVoiceState, VoiceState
77+
from .types.state import PlayerBasicState, PlayerVoiceState, VoiceState
7778

7879
VocalGuildChannel = discord.VoiceChannel | discord.StageChannel
7980

@@ -168,6 +169,26 @@ def __init__(
168169
self._inactivity_task: asyncio.Task[bool] | None = None
169170
self._inactivity_wait: int | None = self._node._inactive_player_timeout
170171

172+
self._should_wait: int = 10
173+
self._reconnecting: asyncio.Event = asyncio.Event()
174+
self._reconnecting.set()
175+
176+
async def _disconnected_wait(self, code: int, by_remote: bool) -> None:
177+
if code != 4014 or not by_remote:
178+
return
179+
180+
self._connected = False
181+
182+
if self._reconnecting.is_set():
183+
await asyncio.sleep(self._should_wait)
184+
else:
185+
await self._reconnecting.wait()
186+
187+
if self._connected:
188+
return
189+
190+
await self._destroy()
191+
171192
def _inactivity_task_callback(self, task: asyncio.Task[bool]) -> None:
172193
cancelled: bool = False
173194

@@ -425,6 +446,89 @@ async def _search(query: str | None) -> T_a:
425446
logger.info('Player "%s" could not load any songs via AutoPlay.', self.guild.id)
426447
self._inactivity_start()
427448

449+
@property
450+
def state(self) -> PlayerBasicState:
451+
"""Property returning a dict of the current basic state of the player.
452+
453+
This property includes the ``voice_state`` received via Discord.
454+
455+
Returns
456+
-------
457+
PlayerBasicState
458+
459+
.. versionadded:: 3.5.0
460+
"""
461+
data: PlayerBasicState = {
462+
"voice_state": self._voice_state.copy(),
463+
"position": self.position,
464+
"connected": self.connected,
465+
"current": self.current,
466+
"paused": self.paused,
467+
"volume": self.volume,
468+
"filters": self.filters,
469+
}
470+
return data
471+
472+
async def switch_node(self, new_node: wavelink.Node, /) -> None:
473+
"""Method which attempts to switch the current node of the player.
474+
475+
This method initiates a live switch, and all player state will be moved from the current node to the provided
476+
node.
477+
478+
.. warning::
479+
480+
Caution should be used when using this method. If this method fails, your player might be left in a stale
481+
state. Consider handling cases where the player is unable to connect to the new node. To avoid stale state
482+
in both wavelink and discord.py, it is recommended to disconnect the player when a RuntimeError occurs.
483+
484+
Parameters
485+
----------
486+
new_node: :class:`wavelink.Node`
487+
A positional only argument of a :class:`wavelink.Node`, which is the new node the player will attempt to
488+
switch to. This must not be the same as the current node.
489+
490+
Raises
491+
------
492+
InvalidNodeException
493+
The provided node was identical to the players current node.
494+
RuntimeError
495+
The player was unable to connect properly to the new node. At this point your player might be in a stale
496+
state. Consider trying another node, or :meth:`disconnect` the player.
497+
498+
499+
.. versionadded:: 3.5.0
500+
"""
501+
assert self._guild
502+
503+
if new_node.identifier == self.node.identifier:
504+
msg: str = f"Player '{self._guild.id}' current node is identical to the passed node: {new_node!r}"
505+
raise InvalidNodeException(msg)
506+
507+
await self._destroy(with_invalidate=False)
508+
self._node = new_node
509+
510+
await self._dispatch_voice_update()
511+
if not self.connected:
512+
raise RuntimeError(f"Switching Node on player '{self._guild.id}' failed. Failed to switch voice_state.")
513+
514+
self.node._players[self._guild.id] = self
515+
516+
if not self._current:
517+
await self.set_filters(self.filters)
518+
await self.set_volume(self.volume)
519+
await self.pause(self.paused)
520+
return
521+
522+
await self.play(
523+
self._current,
524+
replace=True,
525+
start=self.position,
526+
volume=self.volume,
527+
filters=self.filters,
528+
paused=self.paused,
529+
)
530+
logger.debug("Switching nodes for player: '%s' was successful. New Node: %r", self._guild.id, self.node)
531+
428532
@property
429533
def inactive_channel_tokens(self) -> int | None:
430534
"""A settable property which returns the token limit as an ``int`` of the amount of tracks to play before firing
@@ -695,6 +799,7 @@ async def _dispatch_voice_update(self) -> None:
695799
except LavalinkException:
696800
await self.disconnect()
697801
else:
802+
self._connected = True
698803
self._connection_event.set()
699804

700805
logger.debug("Player %s is dispatching VOICE_UPDATE.", self.guild.id)
@@ -772,6 +877,7 @@ async def move_to(
772877
raise InvalidChannelStateException("Player tried to move without a valid guild.")
773878

774879
self._connection_event.clear()
880+
self._reconnecting.clear()
775881
voice: discord.VoiceState | None = self.guild.me.voice
776882

777883
if self_deaf is None and voice:
@@ -786,6 +892,7 @@ async def move_to(
786892
await self.guild.change_voice_state(channel=channel, self_mute=self_mute, self_deaf=self_deaf)
787893

788894
if channel is None:
895+
self._reconnecting.set()
789896
return
790897

791898
try:
@@ -794,6 +901,8 @@ async def move_to(
794901
except (asyncio.TimeoutError, asyncio.CancelledError):
795902
msg = f"Unable to connect to {channel} as it exceeded the timeout of {timeout} seconds."
796903
raise ChannelTimeoutException(msg)
904+
finally:
905+
self._reconnecting.set()
797906

798907
async def play(
799908
self,
@@ -1103,17 +1212,19 @@ def _invalidate(self) -> None:
11031212
except (AttributeError, KeyError):
11041213
pass
11051214

1106-
async def _destroy(self) -> None:
1215+
async def _destroy(self, with_invalidate: bool = True) -> None:
11071216
assert self.guild
11081217

1109-
self._invalidate()
1218+
if with_invalidate:
1219+
self._invalidate()
1220+
11101221
player: Player | None = self.node._players.pop(self.guild.id, None)
11111222

11121223
if player:
11131224
try:
11141225
await self.node._destroy_player(self.guild.id)
1115-
except LavalinkException:
1116-
pass
1226+
except Exception as e:
1227+
logger.debug("Disregarding. Failed to send 'destroy_player' payload to Lavalink: %s", e)
11171228

11181229
def _add_to_previous_seeds(self, seed: str) -> None:
11191230
# Helper method to manage previous seeds.

0 commit comments

Comments
 (0)