diff --git a/docs/wavelink.rst b/docs/wavelink.rst index 16dbe552..a150d7cd 100644 --- a/docs/wavelink.rst +++ b/docs/wavelink.rst @@ -89,6 +89,30 @@ An event listener in a cog. .. versionadded:: 3.1.0 +.. function:: on_wavelink_inactive_player(player: wavelink.Player) + + Triggered when the :attr:`~wavelink.Player.inactive_timeout` countdown expires for the specific :class:`~wavelink.Player`. + + + - See: :attr:`~wavelink.Player.inactive_timeout` + - See: :class:`~wavelink.Node` for setting a default on all players. + + + Examples + -------- + + **Basic Usage:** + + .. code:: python3 + + @commands.Cog.listener() + async def on_wavelink_inactive_player(self, player: wavelink.Player) -> None: + await player.channel.send(f"The player has been inactive for `{player.inactive_timeout}` seconds. Goodbye!") + await player.disconnect() + + + .. versionadded:: 3.2.0 + Types ----- diff --git a/wavelink/node.py b/wavelink/node.py index 63a445e4..fcfa270c 100644 --- a/wavelink/node.py +++ b/wavelink/node.py @@ -119,6 +119,11 @@ class Node: resume_timeout: Optional[int] The seconds this Node should configure Lavalink for resuming its current session in case of network issues. If this is ``0`` or below, resuming will be disabled. Defaults to ``60``. + inactive_player_timeout: int | None + Set the default for :attr:`wavelink.Player.inactive_timeout` on every player that connects to this node. + Defaults to ``300``. + + See also: :func:`on_wavelink_inactive_player`. """ def __init__( @@ -132,6 +137,7 @@ def __init__( retries: int | None = None, client: discord.Client | None = None, resume_timeout: int = 60, + inactive_player_timeout: int | None = 300, ) -> None: self._identifier = identifier or secrets.token_urlsafe(12) self._uri = uri.removesuffix("/") @@ -153,6 +159,13 @@ def __init__( self._websocket: Websocket | None = None + if inactive_player_timeout and inactive_player_timeout < 10: + logger.warn('Setting "inactive_player_timeout" below 10 seconds may result in unwanted side effects.') + + self._inactive_player_timeout = ( + inactive_player_timeout if inactive_player_timeout and inactive_player_timeout > 0 else None + ) + def __repr__(self) -> str: return f"Node(identifier={self.identifier}, uri={self.uri}, status={self.status}, players={len(self.players)})" diff --git a/wavelink/player.py b/wavelink/player.py index 6c8adedd..3c602425 100644 --- a/wavelink/player.py +++ b/wavelink/player.py @@ -47,7 +47,11 @@ ) from .filters import Filters from .node import Pool -from .payloads import PlayerUpdateEventPayload, TrackEndEventPayload +from .payloads import ( + PlayerUpdateEventPayload, + TrackEndEventPayload, + TrackStartEventPayload, +) from .queue import Queue from .tracks import Playable, Playlist @@ -139,8 +143,63 @@ def __init__( self._filters: Filters = Filters() + # Needed for the inactivity checks... + self._inactivity_task: asyncio.Task[bool] | None = None + self._inactivity_wait: int | None = self._node._inactive_player_timeout + + def _inactivity_task_callback(self, task: asyncio.Task[bool]) -> None: + result: bool = task.result() + cancelled: bool = task.cancelled() + + if cancelled or result is False: + logger.debug("Disregarding Inactivity Check Task <%s> as it was previously cancelled.", task.get_name()) + return + + if result is not True: + logger.debug("Disregarding Inactivity Check Task <%s> as it received an unknown result.", task.get_name()) + return + + if not self._guild: + logger.debug("Disregarding Inactivity Check Task <%s> as it has no guild.", task.get_name()) + return + + if self.playing: + logger.debug( + "Disregarding Inactivity Check Task <%s> as Player <%s> is playing.", task.get_name(), self._guild.id + ) + return + + self.client.dispatch("wavelink_inactive_player", self) + logger.debug('Dispatched "on_wavelink_inactive_player" for Player <%s>.', self._guild.id) + + async def _inactivity_runner(self, wait: int) -> bool: + try: + await asyncio.sleep(wait) + except asyncio.CancelledError: + return False + + return True + + def _inactivity_cancel(self) -> None: + if self._inactivity_task: + try: + self._inactivity_task.cancel() + except Exception: + pass + + self._inactivity_task = None + + def _inactivity_start(self) -> None: + if self._inactivity_wait is not None and self._inactivity_wait > 0: + self._inactivity_task = asyncio.create_task(self._inactivity_runner(self._inactivity_wait)) + self._inactivity_task.add_done_callback(self._inactivity_task_callback) + + async def _track_start(self, payload: TrackStartEventPayload) -> None: + self._inactivity_cancel() + async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: if self._autoplay is AutoPlayMode.disabled: + self._inactivity_start() return if self._error_count >= 3: @@ -148,6 +207,7 @@ async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: "AutoPlay was unable to continue as you have received too many consecutive errors." "Please check the error log on Lavalink." ) + self._inactivity_start() return if payload.reason == "replaced": @@ -166,6 +226,7 @@ async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: if not isinstance(self.queue, Queue) or not isinstance(self.auto_queue, Queue): # type: ignore logger.warning(f'"Unable to use AutoPlay on Player for Guild "{self.guild}" due to unsupported Queue.') + self._inactivity_start() return if self.queue.mode is QueueMode.loop: @@ -182,6 +243,10 @@ async def _auto_play_event(self, payload: TrackEndEventPayload) -> None: await self._do_recommendation() async def _do_partial(self, *, history: bool = True) -> None: + # We still do the inactivity start here since if play fails and we have no more tracks... + # we should eventually fire the inactivity event... + self._inactivity_start() + if self._current is None: try: track: Playable = self.queue.get() @@ -195,6 +260,10 @@ async def _do_recommendation(self): assert self.queue.history is not None and self.auto_queue.history is not None if len(self.auto_queue) > self._auto_cutoff + 1: + # We still do the inactivity start here since if play fails and we have no more tracks... + # we should eventually fire the inactivity event... + self._inactivity_start() + track: Playable = self.auto_queue.get() self.auto_queue.history.put(track) @@ -277,6 +346,7 @@ async def _search(query: str | None) -> T_a: if not filtered_r: logger.debug(f'Player "{self.guild.id}" could not load any songs via AutoPlay.') + self._inactivity_start() return if not self._current: @@ -302,6 +372,58 @@ async def _search(query: str | None) -> T_a: random.shuffle(self.auto_queue._queue) logger.debug(f'Player "{self.guild.id}" added "{added}" tracks to the auto_queue via AutoPlay.') + # Probably don't need this here as it's likely to be cancelled instantly... + self._inactivity_start() + + @property + def inactive_timeout(self) -> int | None: + """A property which returns the time as an ``int`` of seconds to wait before this player dispatches the + :func:`on_wavelink_inactive_player` event. + + This property could return ``None`` if no time has been set. + + An inactive player is a player that has not been playing anything for the specified amount of seconds. + + - Pausing the player while a song is playing will not activate this countdown. + - The countdown starts when a track ends and cancels when a track starts. + - The countdown will not trigger until a song is played for the first time or this property is reset. + - The default countdown for all players is set on :class:`~wavelink.Node`. + + This property can be set with a valid ``int`` of seconds to wait before dispatching the + :func:`on_wavelink_inactive_player` event or ``None`` to remove the timeout. + + + .. warning:: + + Setting this to a value of ``0`` or below is the equivalent of setting this property to ``None``. + + + When this property is set, the timeout will reset, and all previously waiting countdowns are cancelled. + + - See: :class:`~wavelink.Node` + - See: :func:`on_wavelink_inactive_player` + + + .. versionadded:: 3.2.0 + """ + return self._inactivity_wait + + @inactive_timeout.setter + def inactive_timeout(self, value: int | None) -> None: + if not value or value <= 0: + self._inactivity_wait = None + self._inactivity_cancel() + return + + if value < 10: + logger.warn('Setting "inactive_timeout" below 10 seconds may result in unwanted side effects.') + + self._inactivity_wait = value + self._inactivity_cancel() + + if self.connected and not self.playing: + self._inactivity_start() + @property def autoplay(self) -> AutoPlayMode: """A property which returns the :class:`wavelink.AutoPlayMode` the player is currently in. @@ -859,6 +981,7 @@ async def skip(self, *, force: bool = True) -> Playable | None: def _invalidate(self) -> None: self._connected = False self._connection_event.clear() + self._inactivity_cancel() try: self.cleanup() diff --git a/wavelink/websocket.py b/wavelink/websocket.py index c13b5e8f..069cf893 100644 --- a/wavelink/websocket.py +++ b/wavelink/websocket.py @@ -197,6 +197,9 @@ async def keep_alive(self) -> None: startpayload: TrackStartEventPayload = TrackStartEventPayload(player=player, track=track) self.dispatch("track_start", startpayload) + if player: + asyncio.create_task(player._track_start(startpayload)) + elif data["type"] == "TrackEndEvent": track: Playable = Playable(data["track"]) reason: str = data["reason"]