Skip to content

Commit

Permalink
Fix/player state (#325)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
EvieePy authored Sep 30, 2024
1 parent 4adb979 commit 12dde9f
Show file tree
Hide file tree
Showing 10 changed files with 217 additions and 16 deletions.
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
18 changes: 10 additions & 8 deletions docs/extensions/attributetable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,6 +14,7 @@
from sphinx.util.docutils import SphinxDirective
from sphinx.util.typing import OptionSpec


if TYPE_CHECKING:
from .builder import DPYHTML5Translator

Expand Down Expand Up @@ -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}.")
Expand All @@ -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:
<div class="py-attribute-table">
Expand Down Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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:
Expand All @@ -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"): [],
}
Expand Down
25 changes: 25 additions & 0 deletions docs/wavelink.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -128,6 +141,11 @@ Types
tracks: wavelink.Search = await wavelink.Playable.search("Ocean Drive")
.. attributetable:: PlayerBasicState

.. autoclass:: PlayerBasicState



Payloads
---------
Expand All @@ -136,6 +154,11 @@ Payloads
.. autoclass:: NodeReadyEventPayload
:members:

.. attributetable:: NodeDisconnectedEventPayload

.. autoclass:: NodeDisconnectedEventPayload
:members:

.. attributetable:: TrackStartEventPayload

.. autoclass:: TrackStartEventPayload
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]" },
]
Expand Down
4 changes: 2 additions & 2 deletions wavelink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand All @@ -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
2 changes: 2 additions & 0 deletions wavelink/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
"""


Expand Down
14 changes: 14 additions & 0 deletions wavelink/payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"PlayerUpdateEventPayload",
"StatsEventPayload",
"NodeReadyEventPayload",
"NodeDisconnectedEventPayload",
"StatsEventMemory",
"StatsEventCPU",
"StatsEventFrames",
Expand Down Expand Up @@ -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.
Expand Down
121 changes: 116 additions & 5 deletions wavelink/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
from .exceptions import (
ChannelTimeoutException,
InvalidChannelStateException,
InvalidNodeException,
LavalinkException,
LavalinkLoadException,
QueueEmpty,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
Loading

0 comments on commit 12dde9f

Please sign in to comment.