Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/player state #325

Merged
merged 8 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading