diff --git a/docs/conf.py b/docs/conf.py
index 3c0484cc..fd19973e 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -21,6 +21,7 @@
import re
import sys
+
sys.path.insert(0, os.path.abspath("."))
sys.path.insert(0, os.path.abspath(".."))
sys.path.append(os.path.abspath("extensions"))
diff --git a/docs/extensions/attributetable.py b/docs/extensions/attributetable.py
index 5640a7ed..3f0ec89e 100644
--- a/docs/extensions/attributetable.py
+++ b/docs/extensions/attributetable.py
@@ -3,7 +3,8 @@
import importlib
import inspect
import re
-from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Sequence, Tuple
+from collections.abc import Sequence
+from typing import TYPE_CHECKING, NamedTuple
from docutils import nodes
from sphinx import addnodes
@@ -13,6 +14,7 @@
from sphinx.util.docutils import SphinxDirective
from sphinx.util.typing import OptionSpec
+
if TYPE_CHECKING:
from .builder import DPYHTML5Translator
@@ -96,7 +98,7 @@ class PyAttributeTable(SphinxDirective):
final_argument_whitespace = False
option_spec: OptionSpec = {}
- def parse_name(self, content: str) -> Tuple[str, str]:
+ def parse_name(self, content: str) -> tuple[str, str]:
match = _name_parser_regex.match(content)
if match is None:
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]:
return modulename, name
- def run(self) -> List[attributetableplaceholder]:
+ def run(self) -> list[attributetableplaceholder]:
"""If you're curious on the HTML this is meant to generate:
@@ -149,7 +151,7 @@ def run(self) -> List[attributetableplaceholder]:
return [node]
-def build_lookup_table(env: BuildEnvironment) -> Dict[str, List[str]]:
+def build_lookup_table(env: BuildEnvironment) -> dict[str, list[str]]:
# Given an environment, load up a lookup table of
# full-class-name: objects
result = {}
@@ -178,7 +180,7 @@ def build_lookup_table(env: BuildEnvironment) -> Dict[str, List[str]]:
class TableElement(NamedTuple):
fullname: str
label: str
- badge: Optional[attributetablebadge]
+ badge: attributetablebadge | None
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) -
def get_class_results(
- lookup: Dict[str, List[str]], modulename: str, name: str, fullname: str
-) -> Dict[str, List[TableElement]]:
+ lookup: dict[str, list[str]], modulename: str, name: str, fullname: str
+) -> dict[str, list[TableElement]]:
module = importlib.import_module(modulename)
cls = getattr(module, name)
- groups: Dict[str, List[TableElement]] = {
+ groups: dict[str, list[TableElement]] = {
_("Attributes"): [],
_("Methods"): [],
}
diff --git a/docs/wavelink.rst b/docs/wavelink.rst
index 633fe471..73eaf0ba 100644
--- a/docs/wavelink.rst
+++ b/docs/wavelink.rst
@@ -34,6 +34,19 @@ An event listener in a cog.
This event can be called many times throughout your bots lifetime, as it will be called when Wavelink successfully
reconnects to your node in the event of a disconnect.
+.. function:: on_wavelink_node_disconnected(payload: wavelink.NodeDisconnectedEventPayload)
+
+ Called when a Node has disconnected/lost connection to wavelink. **This is NOT** the same as a node being closed.
+ This event will however be called directly before the :func:`on_wavelink_node_closed` event.
+
+ The default behaviour is for wavelink to attempt to reconnect a disconnected Node. This event can change that
+ behaviour. If you want to close this node completely see: :meth:`Node.close`
+
+ This event can be used to manage currrently connected players to this Node.
+ See: :meth:`Player.switch_node`
+
+ .. versionadded:: 3.5.0
+
.. function:: on_wavelink_stats_update(payload: wavelink.StatsEventPayload)
Called when the ``stats`` OP is received by Lavalink.
@@ -128,6 +141,11 @@ Types
tracks: wavelink.Search = await wavelink.Playable.search("Ocean Drive")
+.. attributetable:: PlayerBasicState
+
+.. autoclass:: PlayerBasicState
+
+
Payloads
---------
@@ -136,6 +154,11 @@ Payloads
.. autoclass:: NodeReadyEventPayload
:members:
+.. attributetable:: NodeDisconnectedEventPayload
+
+.. autoclass:: NodeDisconnectedEventPayload
+ :members:
+
.. attributetable:: TrackStartEventPayload
.. autoclass:: TrackStartEventPayload
@@ -442,6 +465,8 @@ Exceptions
Exception raised when a :class:`Node` is tried to be retrieved from the
:class:`Pool` without existing, or the ``Pool`` is empty.
+ This exception is also raised when providing an invalid node to :meth:`Player.switch_node`.
+
.. py:exception:: LavalinkException
Exception raised when Lavalink returns an invalid response.
diff --git a/pyproject.toml b/pyproject.toml
index d45a42a1..b4679d57 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "wavelink"
-version = "3.4.2"
+version = "3.5.0"
+
authors = [
{ name="PythonistaGuild, EvieePy", email="evieepy@gmail.com" },
]
diff --git a/wavelink/__init__.py b/wavelink/__init__.py
index 3ab29af2..6bb4ebfd 100644
--- a/wavelink/__init__.py
+++ b/wavelink/__init__.py
@@ -26,8 +26,7 @@
__author__ = "PythonistaGuild, EvieePy"
__license__ = "MIT"
__copyright__ = "Copyright 2019-Present (c) PythonistaGuild, EvieePy"
-__version__ = "3.4.2"
-
+__version__ = "3.5.0"
from .enums import *
from .exceptions import *
@@ -38,4 +37,5 @@
from .player import Player as Player
from .queue import *
from .tracks import *
+from .types.state import PlayerBasicState as PlayerBasicState
from .utils import ExtrasNamespace as ExtrasNamespace
diff --git a/wavelink/exceptions.py b/wavelink/exceptions.py
index 7d395d0e..0069b11e 100644
--- a/wavelink/exceptions.py
+++ b/wavelink/exceptions.py
@@ -82,6 +82,8 @@ class AuthorizationFailedException(WavelinkException):
class InvalidNodeException(WavelinkException):
"""Exception raised when a :class:`Node` is tried to be retrieved from the
:class:`Pool` without existing, or the ``Pool`` is empty.
+
+ This exception is also raised when providing an invalid node to :meth:`~wavelink.Player.switch_node`.
"""
diff --git a/wavelink/payloads.py b/wavelink/payloads.py
index 6d44bd2e..1dbffdd3 100644
--- a/wavelink/payloads.py
+++ b/wavelink/payloads.py
@@ -53,6 +53,7 @@
"PlayerUpdateEventPayload",
"StatsEventPayload",
"NodeReadyEventPayload",
+ "NodeDisconnectedEventPayload",
"StatsEventMemory",
"StatsEventCPU",
"StatsEventFrames",
@@ -87,6 +88,19 @@ def __init__(self, node: Node, resumed: bool, session_id: str) -> None:
self.session_id = session_id
+class NodeDisconnectedEventPayload:
+ """Payload received in the :func:`on_wavelink_node_disconnected` event.
+
+ Attributes
+ ----------
+ node: :class:`~wavelink.Node`
+ The node that has disconnected.
+ """
+
+ def __init__(self, node: Node) -> None:
+ self.node = node
+
+
class TrackStartEventPayload:
"""Payload received in the :func:`on_wavelink_track_start` event.
diff --git a/wavelink/player.py b/wavelink/player.py
index 3f0d1fd2..02ad3e9f 100644
--- a/wavelink/player.py
+++ b/wavelink/player.py
@@ -41,6 +41,7 @@
from .exceptions import (
ChannelTimeoutException,
InvalidChannelStateException,
+ InvalidNodeException,
LavalinkException,
LavalinkLoadException,
QueueEmpty,
@@ -73,7 +74,7 @@
TrackStartEventPayload,
)
from .types.request import Request as RequestPayload
- from .types.state import PlayerVoiceState, VoiceState
+ from .types.state import PlayerBasicState, PlayerVoiceState, VoiceState
VocalGuildChannel = discord.VoiceChannel | discord.StageChannel
@@ -168,6 +169,26 @@ def __init__(
self._inactivity_task: asyncio.Task[bool] | None = None
self._inactivity_wait: int | None = self._node._inactive_player_timeout
+ self._should_wait: int = 10
+ self._reconnecting: asyncio.Event = asyncio.Event()
+ self._reconnecting.set()
+
+ async def _disconnected_wait(self, code: int, by_remote: bool) -> None:
+ if code != 4014 or not by_remote:
+ return
+
+ self._connected = False
+
+ if self._reconnecting.is_set():
+ await asyncio.sleep(self._should_wait)
+ else:
+ await self._reconnecting.wait()
+
+ if self._connected:
+ return
+
+ await self._destroy()
+
def _inactivity_task_callback(self, task: asyncio.Task[bool]) -> None:
cancelled: bool = False
@@ -425,6 +446,89 @@ async def _search(query: str | None) -> T_a:
logger.info('Player "%s" could not load any songs via AutoPlay.', self.guild.id)
self._inactivity_start()
+ @property
+ def state(self) -> PlayerBasicState:
+ """Property returning a dict of the current basic state of the player.
+
+ This property includes the ``voice_state`` received via Discord.
+
+ Returns
+ -------
+ PlayerBasicState
+
+ .. versionadded:: 3.5.0
+ """
+ data: PlayerBasicState = {
+ "voice_state": self._voice_state.copy(),
+ "position": self.position,
+ "connected": self.connected,
+ "current": self.current,
+ "paused": self.paused,
+ "volume": self.volume,
+ "filters": self.filters,
+ }
+ return data
+
+ async def switch_node(self, new_node: wavelink.Node, /) -> None:
+ """Method which attempts to switch the current node of the player.
+
+ This method initiates a live switch, and all player state will be moved from the current node to the provided
+ node.
+
+ .. warning::
+
+ Caution should be used when using this method. If this method fails, your player might be left in a stale
+ state. Consider handling cases where the player is unable to connect to the new node. To avoid stale state
+ in both wavelink and discord.py, it is recommended to disconnect the player when a RuntimeError occurs.
+
+ Parameters
+ ----------
+ new_node: :class:`wavelink.Node`
+ A positional only argument of a :class:`wavelink.Node`, which is the new node the player will attempt to
+ switch to. This must not be the same as the current node.
+
+ Raises
+ ------
+ InvalidNodeException
+ The provided node was identical to the players current node.
+ RuntimeError
+ The player was unable to connect properly to the new node. At this point your player might be in a stale
+ state. Consider trying another node, or :meth:`disconnect` the player.
+
+
+ .. versionadded:: 3.5.0
+ """
+ assert self._guild
+
+ if new_node.identifier == self.node.identifier:
+ msg: str = f"Player '{self._guild.id}' current node is identical to the passed node: {new_node!r}"
+ raise InvalidNodeException(msg)
+
+ await self._destroy(with_invalidate=False)
+ self._node = new_node
+
+ await self._dispatch_voice_update()
+ if not self.connected:
+ raise RuntimeError(f"Switching Node on player '{self._guild.id}' failed. Failed to switch voice_state.")
+
+ self.node._players[self._guild.id] = self
+
+ if not self._current:
+ await self.set_filters(self.filters)
+ await self.set_volume(self.volume)
+ await self.pause(self.paused)
+ return
+
+ await self.play(
+ self._current,
+ replace=True,
+ start=self.position,
+ volume=self.volume,
+ filters=self.filters,
+ paused=self.paused,
+ )
+ logger.debug("Switching nodes for player: '%s' was successful. New Node: %r", self._guild.id, self.node)
+
@property
def inactive_channel_tokens(self) -> int | None:
"""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:
except LavalinkException:
await self.disconnect()
else:
+ self._connected = True
self._connection_event.set()
logger.debug("Player %s is dispatching VOICE_UPDATE.", self.guild.id)
@@ -772,6 +877,7 @@ async def move_to(
raise InvalidChannelStateException("Player tried to move without a valid guild.")
self._connection_event.clear()
+ self._reconnecting.clear()
voice: discord.VoiceState | None = self.guild.me.voice
if self_deaf is None and voice:
@@ -786,6 +892,7 @@ async def move_to(
await self.guild.change_voice_state(channel=channel, self_mute=self_mute, self_deaf=self_deaf)
if channel is None:
+ self._reconnecting.set()
return
try:
@@ -794,6 +901,8 @@ async def move_to(
except (asyncio.TimeoutError, asyncio.CancelledError):
msg = f"Unable to connect to {channel} as it exceeded the timeout of {timeout} seconds."
raise ChannelTimeoutException(msg)
+ finally:
+ self._reconnecting.set()
async def play(
self,
@@ -1103,17 +1212,19 @@ def _invalidate(self) -> None:
except (AttributeError, KeyError):
pass
- async def _destroy(self) -> None:
+ async def _destroy(self, with_invalidate: bool = True) -> None:
assert self.guild
- self._invalidate()
+ if with_invalidate:
+ self._invalidate()
+
player: Player | None = self.node._players.pop(self.guild.id, None)
if player:
try:
await self.node._destroy_player(self.guild.id)
- except LavalinkException:
- pass
+ except Exception as e:
+ logger.debug("Disregarding. Failed to send 'destroy_player' payload to Lavalink: %s", e)
def _add_to_previous_seeds(self, seed: str) -> None:
# Helper method to manage previous seeds.
diff --git a/wavelink/types/state.py b/wavelink/types/state.py
index 2e819dd7..683e6f1d 100644
--- a/wavelink/types/state.py
+++ b/wavelink/types/state.py
@@ -26,6 +26,9 @@
from typing_extensions import NotRequired
+from ..filters import Filters
+from ..tracks import Playable
+
class PlayerState(TypedDict):
time: int
@@ -45,3 +48,33 @@ class PlayerVoiceState(TypedDict):
channel_id: NotRequired[str]
track: NotRequired[str]
position: NotRequired[int]
+
+
+class PlayerBasicState(TypedDict):
+ """A dictionary of basic state for the Player.
+
+ Attributes
+ ----------
+ voice_state: :class:`PlayerVoiceState`
+ The voice state received via Discord. Includes the voice connection ``token``, ``endpoint`` and ``session_id``.
+ position: int
+ The player position.
+ connected: bool
+ Whether the player is currently connected to a channel.
+ current: :class:`~wavelink.Playable` | None
+ The currently playing track or `None` if no track is playing.
+ paused: bool
+ The players paused state.
+ volume: int
+ The players current volume.
+ filters: :class:`~wavelink.Filters`
+ The filters currently assigned to the Player.
+ """
+
+ voice_state: PlayerVoiceState
+ position: int
+ connected: bool
+ current: Playable | None
+ paused: bool
+ volume: int
+ filters: Filters
diff --git a/wavelink/websocket.py b/wavelink/websocket.py
index 950c3b01..422bb42f 100644
--- a/wavelink/websocket.py
+++ b/wavelink/websocket.py
@@ -88,6 +88,12 @@ async def _update_node(self) -> None:
self.node._spotify_enabled = True
async def connect(self) -> None:
+ if self.node._status is NodeStatus.CONNECTED:
+ # Node was previously connected...
+ # We can dispatch an event to say the node was disconnected...
+ payload: NodeDisconnectedEventPayload = NodeDisconnectedEventPayload(node=self.node)
+ self.dispatch("node_disconnected", payload)
+
self.node._status = NodeStatus.CONNECTING
if self.keep_alive_task:
@@ -246,6 +252,9 @@ async def keep_alive(self) -> None:
)
self.dispatch("websocket_closed", wcpayload)
+ if player:
+ asyncio.create_task(player._disconnected_wait(code, by_remote))
+
else:
other_payload: ExtraEventPayload = ExtraEventPayload(node=self.node, player=player, data=data)
self.dispatch("extra_event", other_payload)
@@ -280,4 +289,7 @@ async def cleanup(self) -> None:
self.node._websocket = None
+ payload: NodeDisconnectedEventPayload = NodeDisconnectedEventPayload(node=self.node)
+ self.dispatch("node_disconnected", payload)
+
logger.debug("Successfully cleaned up the websocket for %r", self.node)