diff --git a/src/Lavalink4NET/Players/LavalinkPlayer.cs b/src/Lavalink4NET/Players/LavalinkPlayer.cs index 3e99f021..5c636bce 100644 --- a/src/Lavalink4NET/Players/LavalinkPlayer.cs +++ b/src/Lavalink4NET/Players/LavalinkPlayer.cs @@ -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; @@ -66,6 +71,12 @@ public LavalinkPlayer(IPlayerProperties 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); @@ -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; @@ -813,4 +879,4 @@ static Diagnostics() public static UpDownCounter PlayingPlayers { get; } public static UpDownCounter VoiceServer { get; } -} \ No newline at end of file +} diff --git a/src/Lavalink4NET/Players/LavalinkPlayerOptions.cs b/src/Lavalink4NET/Players/LavalinkPlayerOptions.cs index 5023b205..b506af52 100644 --- a/src/Lavalink4NET/Players/LavalinkPlayerOptions.cs +++ b/src/Lavalink4NET/Players/LavalinkPlayerOptions.cs @@ -10,6 +10,17 @@ public record class LavalinkPlayerOptions public bool DisconnectOnDestroy { get; set; } = true; + /// + /// 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). + /// + public bool EnableVoiceAutoReconnect { get; set; } = true; + + /// + /// Gets or sets the minimum time between automatic voice reconnect attempts. + /// + public TimeSpan VoiceReconnectCooldown { get; set; } = TimeSpan.FromSeconds(10); + public string? Label { get; set; } public ITrackQueueItem? InitialTrack { get; set; } @@ -23,4 +34,4 @@ public record class LavalinkPlayerOptions public bool SelfDeaf { get; set; } public bool SelfMute { get; set; } -} \ No newline at end of file +} diff --git a/tests/Lavalink4NET.Tests/Players/LavalinkPlayerTests.cs b/tests/Lavalink4NET.Tests/Players/LavalinkPlayerTests.cs index d10b083c..be2f9f3b 100644 --- a/tests/Lavalink4NET.Tests/Players/LavalinkPlayerTests.cs +++ b/tests/Lavalink4NET.Tests/Players/LavalinkPlayerTests.cs @@ -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; @@ -121,6 +122,221 @@ public async Task TestPlayerIsDisposedIfDisconnectOnDestroyIsTrueOnChannelDiscon Assert.Equal(PlayerState.Destroyed, player.State); } + [Fact] + public async Task TestVoiceAutoReconnectAttemptsRejoinOn4014Async() + { + // Arrange + var discordClientMock = new Mock(MockBehavior.Strict); + + discordClientMock + .Setup(x => x.SendVoiceUpdateAsync( + guildId: 0UL, + voiceChannelId: 42UL, + selfDeaf: true, + selfMute: true, + It.IsAny())) + .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(MockBehavior.Strict); + + discordClientMock + .Setup(x => x.SendVoiceUpdateAsync( + guildId: 0UL, + voiceChannelId: 42UL, + selfDeaf: false, + selfMute: false, + It.IsAny())) + .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(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(MockBehavior.Strict); + + discordClientMock + .Setup(x => x.SendVoiceUpdateAsync( + guildId: 0UL, + voiceChannelId: 42UL, + selfDeaf: false, + selfMute: false, + It.IsAny())) + .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()), Times.Once); + discordClientMock.VerifyNoOtherCalls(); + } + + [Fact] + public async Task TestVoiceAutoReconnectCooldownIsThreadSafeAsync() + { + // Arrange + var discordClientMock = new Mock(MockBehavior.Strict); + + discordClientMock + .Setup(x => x.SendVoiceUpdateAsync( + guildId: 0UL, + voiceChannelId: 42UL, + selfDeaf: false, + selfMute: false, + It.IsAny())) + .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()), Times.Once); + discordClientMock.VerifyNoOtherCalls(); + } + [Fact] public async Task TestVoiceChannelIdIsUpdatedAfterPlayerMoveAsync() { @@ -826,7 +1042,8 @@ private static PlayerProperties? updateAction = null) + Action? updateAction = null, + Mock? discordClientMock = null) { var sessionId = "abc"; var apiClientMock = new Mock(MockBehavior.Strict); @@ -844,7 +1061,7 @@ private static PlayerProperties x.DestroyPlayerAsync("abc", 0, It.IsAny())) .Returns(ValueTask.CompletedTask); - var discordClientMock = new Mock(); + discordClientMock ??= new Mock(); var sessionProvider = Mock.Of(x => x.GetSessionAsync(playerModel.GuildId, It.IsAny())