Skip to content
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
4 changes: 4 additions & 0 deletions docs/hdi/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.
84 changes: 83 additions & 1 deletion docs/hdi/player.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -683,3 +683,85 @@ After you have initialized your function, you can optionally include the `fast_a
await Player.reset_filters(fast_apply=<True/False>)

```

## 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=[<your segments here>])
```

`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()
```
31 changes: 31 additions & 0 deletions lava_lyra/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
"PlayerConnectedEvent",
"PlayerCreatedEvent",
"SeekEvent",
"SponsorBlockSegmentSkippedEvent",
"SponsorBlockSegmentsLoadedEvent",
"TrackEndEvent",
"TrackExceptionEvent",
"TrackExceptionPayload",
Expand Down Expand Up @@ -472,6 +474,35 @@ def __repr__(self) -> str:
return f"<Lyra.SeekEvent player={self.player!r} position={self.position!r}>"


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"<Lyra.SponsorBlockSegmentsLoadedEvent player={self.player!r} segments={self.segments!r}>"


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"<Lyra.SponsorBlockSegmentSkippedEvent player={self.player!r} segment={self.segment!r}>"


class MixStartedEvent(LyraEvent):
"""Event fired when a mix layer starts (NodeLink specific)

Expand Down
81 changes: 70 additions & 11 deletions lava_lyra/player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
)
Expand All @@ -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:
Expand Down Expand Up @@ -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")
15 changes: 9 additions & 6 deletions lava_lyra/pool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down