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)