Skip to content
Closed
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
70 changes: 68 additions & 2 deletions src/Lavalink4NET/Players/LavalinkPlayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ public class LavalinkPlayer : ILavalinkPlayer, ILavalinkPlayerListener
private readonly ISystemClock _systemClock;
private readonly bool _disconnectOnStop;
private readonly IPlayerLifecycle _playerLifecycle;
private readonly bool _enableVoiceAutoReconnect;
private readonly TimeSpan _voiceReconnectCooldown;
private readonly bool _selfDeaf;
private readonly bool _selfMute;
private long _lastVoiceReconnectAttemptTicks;
private int _disposed;
private DateTimeOffset _syncedAt;
private TimeSpan _unstretchedRelativePosition;
Expand Down Expand Up @@ -66,6 +71,12 @@ public LavalinkPlayer(IPlayerProperties<LavalinkPlayer, LavalinkPlayerOptions> p
_disconnectOnDestroy = properties.Options.Value.DisconnectOnDestroy;
_disconnectOnStop = properties.Options.Value.DisconnectOnStop;

_enableVoiceAutoReconnect = properties.Options.Value.EnableVoiceAutoReconnect;
_voiceReconnectCooldown = properties.Options.Value.VoiceReconnectCooldown;
_selfDeaf = properties.Options.Value.SelfDeaf;
_selfMute = properties.Options.Value.SelfMute;
_lastVoiceReconnectAttemptTicks = 0;

VoiceServer = new VoiceServer(properties.InitialState.VoiceState.Token, properties.InitialState.VoiceState.Endpoint);
VoiceState = new VoiceState(properties.VoiceChannelId, properties.InitialState.VoiceState.SessionId);

Expand Down Expand Up @@ -432,7 +443,62 @@ protected void EnsureNotDestroyed()
#endif
}

protected virtual ValueTask NotifyWebSocketClosedAsync(WebSocketCloseStatus closeStatus, string reason, bool byRemote = false, CancellationToken cancellationToken = default) => default;
protected virtual async ValueTask NotifyWebSocketClosedAsync(WebSocketCloseStatus closeStatus, string reason, bool byRemote = false, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();

// Lavalink reports Discord voice websocket close codes as the websocket close status.
// Recoverable cases commonly include:
// - 4014: Disconnected
// - 4015: Voice server crashed
if (!byRemote || !_enableVoiceAutoReconnect)
{
return;
}

// Player is already destroyed/disposed.
if (_disposed is not 0)
{
return;
}

var closeCode = (int)closeStatus;
if (closeCode is not 4014 and not 4015)
{
return;
}

// If we intentionally left the channel (voice state is null), don't attempt to rejoin.
if (VoiceState.VoiceChannelId is null)
{
return;
}

// Avoid rejoin loops: only attempt once per cooldown window.
var nowTicks = _systemClock.UtcNow.UtcTicks;
var cooldownTicks = _voiceReconnectCooldown.Ticks;

// Atomically claim this attempt. This uses CAS to avoid concurrent callers
// both passing the cooldown check and sending multiple voice updates.
while (true)
{
var lastTicks = Interlocked.Read(ref _lastVoiceReconnectAttemptTicks);

if (cooldownTicks > 0 && lastTicks != 0 && nowTicks - lastTicks < cooldownTicks)
{
return;
}

if (Interlocked.CompareExchange(ref _lastVoiceReconnectAttemptTicks, nowTicks, lastTicks) == lastTicks)
{
break;
}
}

await DiscordClient
.SendVoiceUpdateAsync(GuildId, VoiceChannelId, selfDeaf: _selfDeaf, selfMute: _selfMute, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}

protected virtual ValueTask NotifyTrackEndedAsync(ITrackQueueItem track, TrackEndReason endReason, CancellationToken cancellationToken = default) => default;

Expand Down Expand Up @@ -813,4 +879,4 @@ static Diagnostics()
public static UpDownCounter<int> PlayingPlayers { get; }

public static UpDownCounter<int> VoiceServer { get; }
}
}
13 changes: 12 additions & 1 deletion src/Lavalink4NET/Players/LavalinkPlayerOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ public record class LavalinkPlayerOptions

public bool DisconnectOnDestroy { get; set; } = true;

/// <summary>
/// Gets or sets a value indicating whether Lavalink4NET should attempt to recover voice connectivity
/// by re-sending a voice state update when Discord closes the voice websocket (e.g. 4014/4015).
/// </summary>
public bool EnableVoiceAutoReconnect { get; set; } = true;

/// <summary>
/// Gets or sets the minimum time between automatic voice reconnect attempts.
/// </summary>
public TimeSpan VoiceReconnectCooldown { get; set; } = TimeSpan.FromSeconds(10);

public string? Label { get; set; }

public ITrackQueueItem? InitialTrack { get; set; }
Expand All @@ -23,4 +34,4 @@ public record class LavalinkPlayerOptions
public bool SelfDeaf { get; set; }

public bool SelfMute { get; set; }
}
}
221 changes: 219 additions & 2 deletions tests/Lavalink4NET.Tests/Players/LavalinkPlayerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Net.WebSockets;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -121,6 +122,221 @@ public async Task TestPlayerIsDisposedIfDisconnectOnDestroyIsTrueOnChannelDiscon
Assert.Equal(PlayerState.Destroyed, player.State);
}

[Fact]
public async Task TestVoiceAutoReconnectAttemptsRejoinOn4014Async()
{
// Arrange
var discordClientMock = new Mock<IDiscordClientWrapper>(MockBehavior.Strict);

discordClientMock
.Setup(x => x.SendVoiceUpdateAsync(
guildId: 0UL,
voiceChannelId: 42UL,
selfDeaf: true,
selfMute: true,
It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask);

var playerModel = new PlayerInformationModel(
GuildId: 0UL,
CurrentTrack: null,
Volume: 1F,
IsPaused: false,
VoiceState: CreateVoiceState(),
Filters: new PlayerFilterMapModel());

var options = new LavalinkPlayerOptions
{
EnableVoiceAutoReconnect = true,
VoiceReconnectCooldown = TimeSpan.FromSeconds(10),
SelfDeaf = true,
SelfMute = true,
};

var playerProperties = CreateProperties(playerModel: playerModel, options: options, discordClientMock: discordClientMock);
var player = new LavalinkPlayer(playerProperties);

// Ensure we have a voice channel to re-assert.
var listener = (ILavalinkPlayerListener)player;
await listener.NotifyVoiceStateUpdatedAsync(new VoiceState(VoiceChannelId: 42UL, SessionId: "abc"));

// Act
await listener.NotifyWebSocketClosedAsync((WebSocketCloseStatus)4014, "Disconnected.", byRemote: true);

// Assert
discordClientMock.VerifyAll();
}

[Fact]
public async Task TestVoiceAutoReconnectAttemptsRejoinOn4015Async()
{
// Arrange
var discordClientMock = new Mock<IDiscordClientWrapper>(MockBehavior.Strict);

discordClientMock
.Setup(x => x.SendVoiceUpdateAsync(
guildId: 0UL,
voiceChannelId: 42UL,
selfDeaf: false,
selfMute: false,
It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask);

var playerModel = new PlayerInformationModel(
GuildId: 0UL,
CurrentTrack: null,
Volume: 1F,
IsPaused: false,
VoiceState: CreateVoiceState(),
Filters: new PlayerFilterMapModel());

var options = new LavalinkPlayerOptions
{
EnableVoiceAutoReconnect = true,
VoiceReconnectCooldown = TimeSpan.FromSeconds(10),
SelfDeaf = false,
SelfMute = false,
};

var playerProperties = CreateProperties(playerModel: playerModel, options: options, discordClientMock: discordClientMock);
var player = new LavalinkPlayer(playerProperties);

// Ensure we have a voice channel to re-assert.
var listener = (ILavalinkPlayerListener)player;
await listener.NotifyVoiceStateUpdatedAsync(new VoiceState(VoiceChannelId: 42UL, SessionId: "abc"));

// Act
await listener.NotifyWebSocketClosedAsync((WebSocketCloseStatus)4015, "Voice server crashed.", byRemote: true);

// Assert
discordClientMock.VerifyAll();
}

[Fact]
public async Task TestVoiceAutoReconnectIsSuppressedWhenDisabledAsync()
{
// Arrange
var discordClientMock = new Mock<IDiscordClientWrapper>(MockBehavior.Strict);
var playerModel = new PlayerInformationModel(
GuildId: 0UL,
CurrentTrack: null,
Volume: 1F,
IsPaused: false,
VoiceState: CreateVoiceState(),
Filters: new PlayerFilterMapModel());

var options = new LavalinkPlayerOptions
{
EnableVoiceAutoReconnect = false,
VoiceReconnectCooldown = TimeSpan.FromSeconds(10),
SelfDeaf = false,
SelfMute = false,
};

var playerProperties = CreateProperties(playerModel: playerModel, options: options, discordClientMock: discordClientMock);
var player = new LavalinkPlayer(playerProperties);
var listener = (ILavalinkPlayerListener)player;
await listener.NotifyVoiceStateUpdatedAsync(new VoiceState(VoiceChannelId: 42UL, SessionId: "abc"));

// Act
await listener.NotifyWebSocketClosedAsync((WebSocketCloseStatus)4014, "Disconnected.", byRemote: true);

// Assert
discordClientMock.VerifyNoOtherCalls();
}

[Fact]
public async Task TestVoiceAutoReconnectIsRateLimitedByCooldownAsync()
{
// Arrange
var discordClientMock = new Mock<IDiscordClientWrapper>(MockBehavior.Strict);

discordClientMock
.Setup(x => x.SendVoiceUpdateAsync(
guildId: 0UL,
voiceChannelId: 42UL,
selfDeaf: false,
selfMute: false,
It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask)
.Verifiable();

var playerModel = new PlayerInformationModel(
GuildId: 0UL,
CurrentTrack: null,
Volume: 1F,
IsPaused: false,
VoiceState: CreateVoiceState(),
Filters: new PlayerFilterMapModel());

var options = new LavalinkPlayerOptions
{
EnableVoiceAutoReconnect = true,
VoiceReconnectCooldown = TimeSpan.FromMinutes(1),
SelfDeaf = false,
SelfMute = false,
};

var playerProperties = CreateProperties(playerModel: playerModel, options: options, discordClientMock: discordClientMock);
var player = new LavalinkPlayer(playerProperties);
var listener = (ILavalinkPlayerListener)player;
await listener.NotifyVoiceStateUpdatedAsync(new VoiceState(VoiceChannelId: 42UL, SessionId: "abc"));

// Act
await listener.NotifyWebSocketClosedAsync((WebSocketCloseStatus)4014, "Disconnected.", byRemote: true);
await listener.NotifyWebSocketClosedAsync((WebSocketCloseStatus)4014, "Disconnected.", byRemote: true);

// Assert
discordClientMock.Verify(x => x.SendVoiceUpdateAsync(0UL, 42UL, false, false, It.IsAny<CancellationToken>()), Times.Once);
discordClientMock.VerifyNoOtherCalls();
}

[Fact]
public async Task TestVoiceAutoReconnectCooldownIsThreadSafeAsync()
{
// Arrange
var discordClientMock = new Mock<IDiscordClientWrapper>(MockBehavior.Strict);

discordClientMock
.Setup(x => x.SendVoiceUpdateAsync(
guildId: 0UL,
voiceChannelId: 42UL,
selfDeaf: false,
selfMute: false,
It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask);

var playerModel = new PlayerInformationModel(
GuildId: 0UL,
CurrentTrack: null,
Volume: 1F,
IsPaused: false,
VoiceState: CreateVoiceState(),
Filters: new PlayerFilterMapModel());

var options = new LavalinkPlayerOptions
{
EnableVoiceAutoReconnect = true,
VoiceReconnectCooldown = TimeSpan.FromMinutes(5),
SelfDeaf = false,
SelfMute = false,
};

var playerProperties = CreateProperties(playerModel: playerModel, options: options, discordClientMock: discordClientMock);
var player = new LavalinkPlayer(playerProperties);
var listener = (ILavalinkPlayerListener)player;
await listener.NotifyVoiceStateUpdatedAsync(new VoiceState(VoiceChannelId: 42UL, SessionId: "abc"));

// Act
await Task.WhenAll(
listener.NotifyWebSocketClosedAsync((WebSocketCloseStatus)4014, "Disconnected.", byRemote: true).AsTask(),
listener.NotifyWebSocketClosedAsync((WebSocketCloseStatus)4014, "Disconnected.", byRemote: true).AsTask());

// Assert
discordClientMock.Verify(x => x.SendVoiceUpdateAsync(0UL, 42UL, false, false, It.IsAny<CancellationToken>()), Times.Once);
discordClientMock.VerifyNoOtherCalls();
}

[Fact]
public async Task TestVoiceChannelIdIsUpdatedAfterPlayerMoveAsync()
{
Expand Down Expand Up @@ -826,7 +1042,8 @@ private static PlayerProperties<CustomTracingLavalinkPlayer, LavalinkPlayerOptio
PlayerInformationModel playerModel,
PlayerInformationModel? mutatedPlayerModel = null,
LavalinkPlayerOptions? options = null,
Action<PlayerUpdateProperties>? updateAction = null)
Action<PlayerUpdateProperties>? updateAction = null,
Mock<IDiscordClientWrapper>? discordClientMock = null)
{
var sessionId = "abc";
var apiClientMock = new Mock<ILavalinkApiClient>(MockBehavior.Strict);
Expand All @@ -844,7 +1061,7 @@ private static PlayerProperties<CustomTracingLavalinkPlayer, LavalinkPlayerOptio
.Setup(x => x.DestroyPlayerAsync("abc", 0, It.IsAny<CancellationToken>()))
.Returns(ValueTask.CompletedTask);

var discordClientMock = new Mock<IDiscordClientWrapper>();
discordClientMock ??= new Mock<IDiscordClientWrapper>();

var sessionProvider = Mock.Of<ILavalinkSessionProvider>(x
=> x.GetSessionAsync(playerModel.GuildId, It.IsAny<CancellationToken>())
Expand Down
Loading