diff --git a/docs/hdi/events.md b/docs/hdi/events.md index 5fe436e..22df163 100644 --- a/docs/hdi/events.md +++ b/docs/hdi/events.md @@ -23,6 +23,8 @@ Here is the full list of events: - `SeekEvent` → `on_lyra_seek` - `MixStartedEvent` → `on_lyra_mix_started` - `MixEndedEvent` → `on_lyra_mix_ended` +- `SponsorBlockSegmentsLoadedEvent` → `on_lyra_sponsorblock_segments_loaded` +- `SponsorBlockSegmentSkippedEvent` → `on_lyra_sponsorblock_segment_skipped` Here is an example of how you would listen for the `TrackStartEvent` within a cog: @@ -75,3 +77,5 @@ The following events are only dispatched by NodeLink instances: - `on_lyra_seek(player, position)` — Fired when the player seeks. `position` is the new position in milliseconds. - `on_lyra_mix_started(player, mix_id, track, volume)` — Fired when a mix layer starts. `mix_id` identifies the mix layer, `track` is the `Track` being mixed in (or `None`), and `volume` is the mix layer's volume (`0.0`–`1.0`). - `on_lyra_mix_ended(player, mix_id, reason)` — Fired when a mix layer ends. `reason` is a `MixEndReason` enum (`FINISHED`, `REMOVED`, `ERROR`, or `MAIN_ENDED`). The event also exposes `is_finished`, `is_removed`, `is_error`, and `is_main_ended` boolean properties as shortcuts for checking `reason`. +- `on_lyra_sponsorblock_segments_loaded(player, segments)` — Fired when SponsorBlock segments are loaded for the current track. `segments` is the raw `list[dict[str, Any]]` payload, not a dedicated object. +- `on_lyra_sponsorblock_segment_skipped(player, track, segment)` — Fired when a SponsorBlock segment is skipped. `segment` is the raw `dict[str, Any]` payload, not a dedicated object. diff --git a/docs/hdi/player.md b/docs/hdi/player.md index d9a2c65..b58fbd1 100644 --- a/docs/hdi/player.md +++ b/docs/hdi/player.md @@ -21,7 +21,7 @@ It has a number of functions you will be using frequently: - `Player.fetch_lyrics()` - `Player.subscribe_lyrics()` - `Player.unsubscribe_lyrics()` -- `Player.get_current_lyrics_lines()` +- `Player.get_current_lyrics_lines()`, `Player.get_sponsorblock()`, `Player.set_sponsorblock()`, `Player.set_sponsorblock_segments()`, `Player.clear_sponsorblock()` There are also properties the `Player` class has to access certain values: @@ -683,3 +683,85 @@ After you have initialized your function, you can optionally include the `fast_a await Player.reset_filters(fast_apply=) ``` + +## SponsorBlock + +:::{important} + +Everything in this section is a NodeLink-exclusive feature. All four methods raise `NodelinkExclusive` when +called on a plain Lavalink node, and require NodeLink v3.8.0+. + +::: + +### Reading SponsorBlock settings + +To read the current SponsorBlock configuration for a player, use `Player.get_sponsorblock()` + +```py +await Player.get_sponsorblock() +``` + +This returns the raw `dict[str, Any]` payload NodeLink sends back, including `enabled`, `categories`, +`actionTypes`, `skipMarginMs`, and the currently tracked `segments`. + +### Configuring SponsorBlock + +To change SponsorBlock settings for a player, use `Player.set_sponsorblock()` + +```py +await Player.set_sponsorblock(...) +``` + +After you have initialized your function, we need to fill in the proper parameters: + +:::{list-table} +:header-rows: 1 + +* - Name + - Type + - Description + +* - `enabled` + - `bool | None` + - Whether SponsorBlock should be active for this player. + +* - `categories` + - `list[str] | None` + - The [segment categories](https://wiki.sponsor.ajay.app/w/Segment_Categories) to skip (e.g. `["sponsor", "selfpromo"]`). +* - `action_types` + - `list[str] | None` + - The action types to apply to matched segments. + +* - `skip_margin_ms` + - `int | None` + - Margin in milliseconds applied around a segment boundary before skipping. + +::: + +Only the parameters you pass are sent to NodeLink, anything left as `None` keeps its current value. + +```py +await Player.set_sponsorblock( + enabled=True, + categories=["sponsor", "selfpromo"], +) +``` + +### Overriding segments manually + +To set SponsorBlock segments for the current track directly, instead of relying on NodeLink to fetch them, +use `Player.set_sponsorblock_segments()` + +```py +await Player.set_sponsorblock_segments(segments=[]) +``` + +`segments` is a `list[dict[str, Any]]` of raw segment payloads. + +### Clearing SponsorBlock state + +To clear SponsorBlock state (settings and segments) for a player, use `Player.clear_sponsorblock()` + +```py +await Player.clear_sponsorblock() +``` diff --git a/lava_lyra/events.py b/lava_lyra/events.py index 79a831e..0dca83d 100644 --- a/lava_lyra/events.py +++ b/lava_lyra/events.py @@ -27,6 +27,8 @@ "PlayerConnectedEvent", "PlayerCreatedEvent", "SeekEvent", + "SponsorBlockSegmentSkippedEvent", + "SponsorBlockSegmentsLoadedEvent", "TrackEndEvent", "TrackExceptionEvent", "TrackExceptionPayload", @@ -472,6 +474,35 @@ def __repr__(self) -> str: return f"" +class SponsorBlockSegmentsLoadedEvent(LyraEvent): + name = "sponsorblock_segments_loaded" + __slots__ = ("player", "segments") + + def __init__(self, data: dict[str, Any], player: Player): + self.player: Player = player + self.segments: list[dict[str, Any]] = data.get("segments", []) + + self.handler_args = self.player, self.segments + + def __repr__(self) -> str: + return f"" + + +class SponsorBlockSegmentSkippedEvent(LyraEvent): + name = "sponsorblock_segment_skipped" + __slots__ = ("player", "segment", "track") + + def __init__(self, data: dict[str, Any], player: Player): + self.player: Player = player + self.track: Track | None = player._current + self.segment: dict[str, Any] = data.get("segment", {}) + + self.handler_args = self.player, self.track, self.segment + + def __repr__(self) -> str: + return f"" + + class MixStartedEvent(LyraEvent): """Event fired when a mix layer starts (NodeLink specific) diff --git a/lava_lyra/player.py b/lava_lyra/player.py index 7e79d2a..59fb8ed 100644 --- a/lava_lyra/player.py +++ b/lava_lyra/player.py @@ -614,10 +614,8 @@ async def connect( async def stop(self, *, gapless: bool = False) -> None: """Stops the currently playing track.""" - if gapless and not self._node._is_nodelink: - raise NodelinkExclusive( - "This is a Nodelink-exclusive feature and is not supported on a Lavalink instance" - ) + if gapless: + self._require_nodelink() if not gapless: self._current = None self._next_track = None @@ -671,10 +669,8 @@ async def play( ) -> Track | None: """Plays a track. If a Spotify or Apple Music track is passed in, it will be handled accordingly.""" - if gapless and not self._node._is_nodelink: - raise NodelinkExclusive( - "This is a Nodelink-exclusive feature and is not supported on a Lavalink instance" - ) + if gapless: + self._require_nodelink() if not self._node._available or not self._node._session_id: if self._log: @@ -805,14 +801,20 @@ async def play( return self._current if not gapless else self._next_track async def _send_player_request( - self, data: dict[str, Any], method: str = "PATCH", query: str | None = None + self, + data: dict[str, Any] | None = None, + method: str = "PATCH", + query: str | None = None, + endpoint_suffix: str | None = None, ) -> Any: """Auxiliary method for sending player requests, including error handling""" + guild_id = f"{self._guild.id}/{endpoint_suffix}" if endpoint_suffix else self._guild.id + try: return await self._node.send( method=method, path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=guild_id, data=data, query=query, ) @@ -825,13 +827,27 @@ async def _send_player_request( return await self._node.send( method=method, path=self._player_endpoint_uri, - guild_id=self._guild.id, + guild_id=guild_id, data=data, query=query, ) else: raise + def _require_nodelink( + self, min_version: LavalinkVersion | None = None, feature_name: str | None = None + ) -> None: + """Guards a NodeLink-exclusive Player method.""" + if not self._node._is_nodelink: + raise NodelinkExclusive( + "This is a Nodelink-exclusive feature and is not supported on a Lavalink instance" + ) + if min_version is not None and self._node._version < min_version: + version_str = f"{min_version.major}.{min_version.minor}.{min_version.fix}" + raise NodelinkExclusive( + f"{feature_name} requires NodeLink {version_str}+. Current node does not support it." + ) + async def seek(self, position: float) -> float: """Seeks to a position, in milliseconds, in the currently playing track.""" if not self._current or not self._current.original: @@ -970,3 +986,46 @@ async def reset_filters(self, *, fast_apply: bool = False) -> None: if self._log: self._log.debug("Fast apply passed, now removing all filters instantly.") await self.seek(self.position) + + async def get_sponsorblock(self) -> dict[str, Any]: + """Requires NodeLink v3.8.0+""" + self._require_nodelink(LavalinkVersion(3, 8, 0), "SponsorBlock") + + return await self._send_player_request(method="GET", endpoint_suffix="sponsorblock") + + async def set_sponsorblock( + self, + *, + enabled: bool | None = None, + categories: list[str] | None = None, + action_types: list[str] | None = None, + skip_margin_ms: int | None = None, + ) -> dict[str, Any]: + """Requires NodeLink v3.8.0+""" + self._require_nodelink(LavalinkVersion(3, 8, 0), "SponsorBlock") + + data: dict[str, Any] = {} + if enabled is not None: + data["enabled"] = enabled + if categories is not None: + data["categories"] = categories + if action_types is not None: + data["actionTypes"] = action_types + if skip_margin_ms is not None: + data["skipMarginMs"] = skip_margin_ms + + return await self._send_player_request(data, endpoint_suffix="sponsorblock") + + async def set_sponsorblock_segments(self, segments: list[dict[str, Any]]) -> dict[str, Any]: + """Requires NodeLink v3.8.0+""" + self._require_nodelink(LavalinkVersion(3, 8, 0), "SponsorBlock") + + return await self._send_player_request( + {"segments": segments}, method="POST", endpoint_suffix="sponsorblock" + ) + + async def clear_sponsorblock(self) -> None: + """Requires NodeLink v3.8.0+""" + self._require_nodelink(LavalinkVersion(3, 8, 0), "SponsorBlock") + + await self._send_player_request(method="DELETE", endpoint_suffix="sponsorblock") diff --git a/lava_lyra/pool.py b/lava_lyra/pool.py index d834a66..efdab34 100644 --- a/lava_lyra/pool.py +++ b/lava_lyra/pool.py @@ -640,13 +640,16 @@ async def send( if data.get("endTime") is None: data.pop("endTime", None) + request_kwargs: dict[str, Any] = { + "method": method, + "url": uri, + "headers": self._headers, + } + if data is not None: + request_kwargs["json"] = data + try: - resp = await self._session.request( - method=method, - url=uri, - headers=self._headers, - json=data or {}, - ) + resp = await self._session.request(**request_kwargs) if self._log: self._log.debug(