From d4a837f9592d16194ac50fe82329809b615a6697 Mon Sep 17 00:00:00 2001 From: Oliver Wilkes Date: Sat, 28 Jan 2023 22:42:13 +0000 Subject: [PATCH] feat: add event dispatching (#28) --- .flake8 | 4 +- mafic/__init__.py | 1 + mafic/errors.py | 16 ++-- mafic/events.py | 190 ++++++++++++++++++++++++++++++++++++++ mafic/node.py | 24 +---- mafic/player.py | 77 ++++++++++++++- mafic/typings/http.py | 4 +- mafic/typings/incoming.py | 5 +- mafic/typings/misc.py | 7 +- 9 files changed, 290 insertions(+), 38 deletions(-) create mode 100644 mafic/events.py diff --git a/.flake8 b/.flake8 index 26dd422..86b5767 100644 --- a/.flake8 +++ b/.flake8 @@ -1,7 +1,7 @@ [flake8] max-line-length = 88 per-file-ignores = - __init__.py: F401,F403 + __init__.py: F401 # Black already handles E501 - line too long, ignored for docstring anomolies. # W503 - line break before binary operator, ignored for Black, flake8 cannot decide what style lmao. -extend-ignore = E501,W503 +extend-ignore = E501,W503,F403,F405 diff --git a/mafic/__init__.py b/mafic/__init__.py index d0db380..c451e54 100644 --- a/mafic/__init__.py +++ b/mafic/__init__.py @@ -12,6 +12,7 @@ from . import __libraries from .errors import * +from .events import * from .filter import * from .ip import * from .node import * diff --git a/mafic/errors.py b/mafic/errors.py index fe48649..34ace8d 100644 --- a/mafic/errors.py +++ b/mafic/errors.py @@ -7,7 +7,7 @@ if TYPE_CHECKING: from typing_extensions import Self - from .typings import ExceptionSeverity, FriendlyException + from .typings import ExceptionSeverity, LavalinkException __all__ = ( "LibraryCompatibilityError", @@ -68,14 +68,16 @@ class TrackLoadException(PlayerException): The severity of the error. """ - def __init__(self, *, message: str, severity: ExceptionSeverity) -> None: + def __init__( + self, *, message: str, severity: ExceptionSeverity, cause: str + ) -> None: super().__init__(f"The track could not be loaded: {message} ({severity} error)") - self.message = message - self.severity = severity + self.message: str = message + self.severity: ExceptionSeverity = severity @classmethod - def from_data(cls, data: FriendlyException) -> Self: + def from_data(cls, data: LavalinkException) -> Self: """Construct a new TrackLoadException from raw Lavalink data. Parameters @@ -89,7 +91,9 @@ def from_data(cls, data: FriendlyException) -> Self: The constructed exception. """ - return cls(message=data["message"], severity=data["severity"]) + return cls( + message=data["message"], severity=data["severity"], cause=data["cause"] + ) class PlayerNotConnected(PlayerException): diff --git a/mafic/events.py b/mafic/events.py new file mode 100644 index 0000000..3f02731 --- /dev/null +++ b/mafic/events.py @@ -0,0 +1,190 @@ +# SPDX-License-Identifier: MIT +# pyright: reportImportCycles=false +# Player import. + +from __future__ import annotations + +from enum import Enum +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .player import Player + from .track import Track + from .type_variables import ClientT + from .typings import ( + LavalinkException, + TrackEndEvent as TrackEndEventPayload, + TrackExceptionEvent as TrackExceptionEventPayload, + TrackStuckEvent as TrackStuckEventPayload, + WebSocketClosedEvent as WebSocketClosedEventPayload, + ) + +__all__ = ( + "EndReason", + "TrackEndEvent", + "TrackExceptionEvent", + "TrackStartEvent", + "TrackStuckEvent", + "WebSocketClosedEvent", +) + + +class EndReason(str, Enum): + """Represents the reason why a track ended.""" + + FINISHED = "FINISHED" + """The track finished playing.""" + + LOAD_FAILED = "LOAD_FAILED" + """The track failed to load.""" + + STOPPED = "STOPPED" + """The track was stopped.""" + + REPLACED = "REPLACED" + """The track was replaced.""" + + CLEANUP = "CLEANUP" + """The track was cleaned up.""" + + +class WebSocketClosedEvent: + """Represents an event when the connection to Discord is lost. + + Attributes + ---------- + code: :class:`int` + The close code. + Find what this can be in the Discord `docs`_. + + .. _docs: https://discord.com/developers/docs/topics/opcodes-and-status-codes#close-event-codes. + reason: :class:`str` + The close reason. + by_discord: :class:`bool` + Whether the close was initiated by Discord. + player: :class:`Player` + The player that the event was dispatched from. + """ + + __slots__ = ("code", "reason", "by_discord", "player") + + def __init__( + self, *, payload: WebSocketClosedEventPayload, player: Player[ClientT] + ): + self.code: int = payload["code"] + self.reason: str = payload["reason"] + self.by_discord: bool = payload["byRemote"] + self.player: Player[ClientT] = player + + def __repr__(self) -> str: + return ( + f"" + ) + + +class TrackStartEvent: + """Represents an event when a track starts playing. + + Attributes + ---------- + track: :class:`Track` + The track that started playing. + player: :class:`Player` + The player that the event was dispatched from. + """ + + __slots__ = ("track", "player") + + def __init__(self, *, track: Track, player: Player[ClientT]): + self.track: Track = track + self.player: Player[ClientT] = player + + def __repr__(self) -> str: + return f"" + + +class TrackEndEvent: + """Represents an event when a track ends. + + Attributes + ---------- + track: :class:`Track` + The track that ended. + reason: :class:`EndReason` + The reason why the track ended. + player: :class:`Player` + The player that the event was dispatched from. + """ + + __slots__ = ("track", "reason", "player") + + def __init__( + self, *, track: Track, payload: TrackEndEventPayload, player: Player[ClientT] + ): + self.track: Track = track + self.reason: EndReason = EndReason(payload["reason"]) + self.player: Player[ClientT] = player + + def __repr__(self) -> str: + return f"" + + +class TrackExceptionEvent: + """Represents an event when an exception occurs while playing a track. + + Attributes + ---------- + track: :class:`Track` + The track that caused the exception. + exception: :class:`Exception` + The exception that was raised. + player: :class:`Player` + The player that the event was dispatched from. + """ + + __slots__ = ("track", "exception", "player") + + def __init__( + self, + *, + track: Track, + payload: TrackExceptionEventPayload, + player: Player[ClientT], + ): + self.track: Track = track + self.exception: LavalinkException = payload["exception"] + self.player: Player[ClientT] = player + + def __repr__(self) -> str: + return ( + f"" + ) + + +class TrackStuckEvent: + """Represents an event when a track gets stuck. + + Attributes + ---------- + track: :class:`Track` + The track that got stuck. + threshold_ms: :class:`int` + The threshold in milliseconds that was exceeded. + player: :class:`Player` + The player that the event was dispatched from. + """ + + __slots__ = ("track", "threshold_ms", "player") + + def __init__( + self, *, track: Track, payload: TrackStuckEventPayload, player: Player[ClientT] + ): + self.track: Track = track + self.threshold_ms: int = payload["thresholdMs"] + self.player: Player[ClientT] = player + + def __repr__(self) -> str: + return ( + f"" + ) diff --git a/mafic/node.py b/mafic/node.py index cc05b5e..4633dbc 100644 --- a/mafic/node.py +++ b/mafic/node.py @@ -626,27 +626,13 @@ async def _handle_event(self, data: EventPayload) -> None: The data to handle. """ - if data["type"] == "WebSocketClosedEvent": - # TODO: - ... - elif data["type"] == "TrackStartEvent": - # We do not care about track starts, the user is already aware of it. + if not (player := self.players.get(int(data["guildId"]))): + _log.error( + "Could not find player for guild %s, discarding event.", data["guildId"] + ) return - elif data["type"] == "TrackEndEvent": - # TODO: - ... - elif data["type"] == "TrackExceptionEvent": - # TODO: - ... - elif data["type"] == "TrackStuckEvent": - # TODO: - ... - else: - # Pyright expects this to never happen, so do I, I really hope. - # Nobody expects the Spanish Inquisition, neither does pyright. - event_type = cast(str, data["type"]) - _log.warning("Unknown incoming event type %s", event_type) + player.dispatch_event(data) def voice_update( self, diff --git a/mafic/player.py b/mafic/player.py index 41f615b..dc5829d 100644 --- a/mafic/player.py +++ b/mafic/player.py @@ -7,7 +7,7 @@ from logging import getLogger from operator import or_ from time import time -from typing import TYPE_CHECKING, Generic +from typing import TYPE_CHECKING, Generic, cast from .__libraries import ( MISSING, @@ -17,6 +17,7 @@ VoiceProtocol, ) from .errors import PlayerNotConnected +from .events import * from .filter import Filter from .playlist import Playlist from .pool import NodePool @@ -32,7 +33,7 @@ VoiceServerUpdatePayload, ) from .node import Node - from .typings import PlayerUpdateState + from .typings import EventPayload, PlayerUpdateState _log = getLogger(__name__) @@ -94,6 +95,8 @@ def __init__( self._ping = -1 self._current: Track | None = None self._filters: OrderedDict[str, Filter] = OrderedDict() + # Used to get the last track for TrackEndEvent. + self._last_track: Track | None = None @property def connected(self) -> bool: @@ -135,6 +138,12 @@ def node(self) -> Node[ClientT]: return self._node + @property + def current(self) -> Track | None: + """The current track that is playing.""" + + return self._current + def update_state(self, state: PlayerUpdateState) -> None: """Update the player state. @@ -181,6 +190,70 @@ async def _dispatch_player_update(self) -> None: data=self._server_state, ) + def dispatch_event(self, data: EventPayload) -> None: + if data["type"] == "WebSocketClosedEvent": + event = WebSocketClosedEvent(payload=data, player=self) + _log.debug("Received websocket closed event: %s", event) + self.client.dispatch("websocket_closed", event) + elif data["type"] == "TrackStartEvent": + track = self._current + + if track is None: + _log.error( + "Received track start event but no track was playing, discarding." + ) + return + + event = TrackStartEvent(player=self, track=track) + self.client.dispatch("track_start", event) + _log.debug("Received track start event: %s", event) + self._last_track = track + elif data["type"] == "TrackEndEvent": + track = self._last_track + + if track is None: + _log.error( + "Received track end event but no track was playing, discarding." + ) + return + + event = TrackEndEvent(player=self, track=track, payload=data) + self.client.dispatch("track_end", event) + _log.debug("Received track end event: %s", event) + + if data["reason"] != "REPLACED": + self._current = None + elif data["type"] == "TrackExceptionEvent": + track = self._current + + if track is None: + _log.error( + "Received track exception event but no track was playing, discarding." + ) + return + + event = TrackExceptionEvent(player=self, track=track, payload=data) + self.client.dispatch("track_exception", event) + _log.debug("Received track exception event: %s", event) + elif data["type"] == "TrackStuckEvent": + track = self._current + + if track is None: + _log.error( + "Received track stuck event but no track was playing, discarding." + ) + return + + event = TrackStuckEvent(player=self, track=track, payload=data) + self.client.dispatch("track_stuck", event) + _log.debug("Received track stuck event: %s", event) + else: + # Pyright expects this to never happen, so do I, I really hope. + # Nobody expects the Spanish Inquisition, neither does pyright. + + event_type = cast(str, data["type"]) + _log.warning("Unknown incoming event type %s", event_type) + async def on_voice_state_update(self, data: GuildVoiceStatePayload) -> None: """Dispatch a voice state update. diff --git a/mafic/typings/http.py b/mafic/typings/http.py index 9b12fac..16a7224 100644 --- a/mafic/typings/http.py +++ b/mafic/typings/http.py @@ -9,7 +9,7 @@ if TYPE_CHECKING: from typing_extensions import NotRequired - from .misc import FriendlyException + from .misc import LavalinkException __all__ = ( @@ -61,7 +61,7 @@ class GenericTracks(TypedDict): class TracksFailed(TypedDict): loadType: Literal["LOAD_FAILED"] - exception: FriendlyException + exception: LavalinkException class NoMatches(TypedDict): diff --git a/mafic/typings/incoming.py b/mafic/typings/incoming.py index 1365531..b26815c 100644 --- a/mafic/typings/incoming.py +++ b/mafic/typings/incoming.py @@ -12,7 +12,7 @@ from typing_extensions import NotRequired - from .misc import FriendlyWithCause + from .misc import LavalinkException __all__ = ( "EventPayload", @@ -23,6 +23,7 @@ "TrackExceptionEvent", "TrackStartEvent", "TrackStuckEvent", + "WebSocketClosedEvent", ) @@ -69,7 +70,7 @@ class TrackExceptionEvent(PayloadWithGuild): op: Literal["event"] type: Literal["TrackExceptionEvent"] encodedTrack: str - error: FriendlyWithCause + exception: LavalinkException class TrackStuckEvent(PayloadWithGuild): diff --git a/mafic/typings/misc.py b/mafic/typings/misc.py index 40d6109..0b0e0fb 100644 --- a/mafic/typings/misc.py +++ b/mafic/typings/misc.py @@ -6,8 +6,8 @@ __all__ = ( "Coro", + "LavalinkException", "ExceptionSeverity", - "FriendlyException", "PayloadWithGuild", ) T = TypeVar("T") @@ -16,12 +16,9 @@ ExceptionSeverity = Literal["COMMON", "SUSPICIOUS", "FAULT"] -class FriendlyException(TypedDict): +class LavalinkException(TypedDict): severity: ExceptionSeverity message: str - - -class FriendlyWithCause(FriendlyException): cause: str