From 1049f1ac984bcf37f2ec76f7c2e83bded52199c5 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 00:35:15 +0200 Subject: [PATCH 01/19] feat: Implement voice server interception --- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Players/ILavalinkVoiceServerInterceptor.cs | 13 +++++++++++++ src/Lavalink4NET/Players/IPlayerProperties.cs | 2 ++ src/Lavalink4NET/Players/LavalinkPlayer.cs | 13 ++++++++++--- .../Players/LavalinkVoiceServerInterceptor.cs | 16 ++++++++++++++++ src/Lavalink4NET/Players/PlayerContext.cs | 3 ++- src/Lavalink4NET/Players/PlayerManager.cs | 5 ++++- src/Lavalink4NET/Players/PlayerProperties.cs | 2 ++ 8 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 src/Lavalink4NET/Players/ILavalinkVoiceServerInterceptor.cs create mode 100644 src/Lavalink4NET/Players/LavalinkVoiceServerInterceptor.cs diff --git a/src/Lavalink4NET/Extensions/ServiceCollectionExtensions.cs b/src/Lavalink4NET/Extensions/ServiceCollectionExtensions.cs index 9aaf9342..337865b3 100644 --- a/src/Lavalink4NET/Extensions/ServiceCollectionExtensions.cs +++ b/src/Lavalink4NET/Extensions/ServiceCollectionExtensions.cs @@ -31,6 +31,7 @@ public static IServiceCollection AddLavalinkCore(this IServiceCollection service services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.AddHostedService(); diff --git a/src/Lavalink4NET/Players/ILavalinkVoiceServerInterceptor.cs b/src/Lavalink4NET/Players/ILavalinkVoiceServerInterceptor.cs new file mode 100644 index 00000000..ca89d8a1 --- /dev/null +++ b/src/Lavalink4NET/Players/ILavalinkVoiceServerInterceptor.cs @@ -0,0 +1,13 @@ +namespace Lavalink4NET.Players; + +using System.Threading; +using System.Threading.Tasks; +using Lavalink4NET.Clients; + +public interface ILavalinkVoiceServerInterceptor +{ + ValueTask InterceptAsync( + ulong guildId, + VoiceServer voiceServer, + CancellationToken cancellationToken = default); +} diff --git a/src/Lavalink4NET/Players/IPlayerProperties.cs b/src/Lavalink4NET/Players/IPlayerProperties.cs index 33d88b78..fae7ef7c 100644 --- a/src/Lavalink4NET/Players/IPlayerProperties.cs +++ b/src/Lavalink4NET/Players/IPlayerProperties.cs @@ -30,6 +30,8 @@ public interface IPlayerProperties IServiceProvider? ServiceProvider { get; } + ILavalinkVoiceServerInterceptor VoiceServerInterceptor { get; } + ulong VoiceChannelId { get; } string SessionId { get; } diff --git a/src/Lavalink4NET/Players/LavalinkPlayer.cs b/src/Lavalink4NET/Players/LavalinkPlayer.cs index ea4c63d5..217f73b8 100644 --- a/src/Lavalink4NET/Players/LavalinkPlayer.cs +++ b/src/Lavalink4NET/Players/LavalinkPlayer.cs @@ -29,6 +29,7 @@ public class LavalinkPlayer : ILavalinkPlayer, ILavalinkPlayerListener private readonly ISystemClock _systemClock; private readonly bool _disconnectOnStop; private readonly IPlayerLifecycle _playerLifecycle; + private readonly ILavalinkVoiceServerInterceptor _voiceServerInterceptor; private int _disposed; private DateTimeOffset _syncedAt; private TimeSpan _unstretchedRelativePosition; @@ -56,7 +57,9 @@ public LavalinkPlayer(IPlayerProperties p _systemClock = properties.SystemClock; _logger = properties.Logger; _syncedAt = properties.SystemClock.UtcNow; + _playerLifecycle = properties.Lifecycle; + _voiceServerInterceptor = properties.VoiceServerInterceptor; _unstretchedRelativePosition = default; _connectedOnce = false; @@ -646,17 +649,21 @@ protected virtual async ValueTask NotifyVoiceStateUpdatedAsync(VoiceState voiceS } } - protected virtual ValueTask NotifyVoiceServerUpdatedAsync(VoiceServer voiceServer, CancellationToken cancellationToken = default) + protected virtual async ValueTask NotifyVoiceServerUpdatedAsync(VoiceServer voiceServer, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (_disposed is 1) { - return ValueTask.CompletedTask; + return; } + voiceServer = await _voiceServerInterceptor + .InterceptAsync(GuildId, voiceServer, cancellationToken) + .ConfigureAwait(false); + VoiceServer = voiceServer; - return UpdateVoiceCredentialsAsync(cancellationToken); + await UpdateVoiceCredentialsAsync(cancellationToken).ConfigureAwait(false); } ValueTask ILavalinkPlayerListener.NotifyVoiceStateUpdatedAsync(VoiceState voiceState, CancellationToken cancellationToken) diff --git a/src/Lavalink4NET/Players/LavalinkVoiceServerInterceptor.cs b/src/Lavalink4NET/Players/LavalinkVoiceServerInterceptor.cs new file mode 100644 index 00000000..c83f96fb --- /dev/null +++ b/src/Lavalink4NET/Players/LavalinkVoiceServerInterceptor.cs @@ -0,0 +1,16 @@ +namespace Lavalink4NET.Players; + +using System.Threading; +using System.Threading.Tasks; +using Lavalink4NET.Clients; + +internal sealed class LavalinkVoiceServerInterceptor : ILavalinkVoiceServerInterceptor +{ + public ValueTask InterceptAsync( + ulong guildId, + VoiceServer voiceServer, + CancellationToken cancellationToken = default) + { + return new ValueTask(voiceServer); + } +} \ No newline at end of file diff --git a/src/Lavalink4NET/Players/PlayerContext.cs b/src/Lavalink4NET/Players/PlayerContext.cs index f3d35708..22a7d728 100644 --- a/src/Lavalink4NET/Players/PlayerContext.cs +++ b/src/Lavalink4NET/Players/PlayerContext.cs @@ -9,4 +9,5 @@ internal sealed record class PlayerContext( ILavalinkSessionProvider SessionProvider, IDiscordClientWrapper DiscordClient, ISystemClock SystemClock, - IPlayerLifecycleNotifier? LifecycleNotifier); + IPlayerLifecycleNotifier? LifecycleNotifier, + ILavalinkVoiceServerInterceptor VoiceServerInterceptor); diff --git a/src/Lavalink4NET/Players/PlayerManager.cs b/src/Lavalink4NET/Players/PlayerManager.cs index 376c5bd3..ce9724d2 100644 --- a/src/Lavalink4NET/Players/PlayerManager.cs +++ b/src/Lavalink4NET/Players/PlayerManager.cs @@ -30,11 +30,13 @@ public PlayerManager( IServiceProvider? serviceProvider, IDiscordClientWrapper discordClient, ILavalinkSessionProvider sessionProvider, + ILavalinkVoiceServerInterceptor voiceServerInterceptor, ISystemClock systemClock, ILoggerFactory loggerFactory) { ArgumentNullException.ThrowIfNull(discordClient); ArgumentNullException.ThrowIfNull(sessionProvider); + ArgumentNullException.ThrowIfNull(voiceServerInterceptor); ArgumentNullException.ThrowIfNull(systemClock); ArgumentNullException.ThrowIfNull(loggerFactory); @@ -49,7 +51,8 @@ public PlayerManager( SessionProvider: sessionProvider, DiscordClient: discordClient, SystemClock: systemClock, - LifecycleNotifier: this); + LifecycleNotifier: this, + VoiceServerInterceptor: voiceServerInterceptor); DiscordClient.VoiceStateUpdated += OnVoiceStateUpdated; DiscordClient.VoiceServerUpdated += OnVoiceServerUpdated; diff --git a/src/Lavalink4NET/Players/PlayerProperties.cs b/src/Lavalink4NET/Players/PlayerProperties.cs index 084d330d..eb5c0bd4 100644 --- a/src/Lavalink4NET/Players/PlayerProperties.cs +++ b/src/Lavalink4NET/Players/PlayerProperties.cs @@ -28,4 +28,6 @@ internal sealed record class PlayerProperties( public IServiceProvider? ServiceProvider => Context.ServiceProvider; public ISystemClock SystemClock => Context.SystemClock; + + public ILavalinkVoiceServerInterceptor VoiceServerInterceptor => Context.VoiceServerInterceptor; } From 1046709a43d9b31161c39c91f98859f9cd8a81bf Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 01:26:27 +0200 Subject: [PATCH 02/19] fix: Fix initial voice server intercept --- src/Lavalink4NET/Players/LavalinkPlayer.cs | 7 ------- src/Lavalink4NET/Players/LavalinkPlayerHandle.cs | 4 +++- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Lavalink4NET/Players/LavalinkPlayer.cs b/src/Lavalink4NET/Players/LavalinkPlayer.cs index 217f73b8..5ba54eb3 100644 --- a/src/Lavalink4NET/Players/LavalinkPlayer.cs +++ b/src/Lavalink4NET/Players/LavalinkPlayer.cs @@ -29,7 +29,6 @@ public class LavalinkPlayer : ILavalinkPlayer, ILavalinkPlayerListener private readonly ISystemClock _systemClock; private readonly bool _disconnectOnStop; private readonly IPlayerLifecycle _playerLifecycle; - private readonly ILavalinkVoiceServerInterceptor _voiceServerInterceptor; private int _disposed; private DateTimeOffset _syncedAt; private TimeSpan _unstretchedRelativePosition; @@ -57,9 +56,7 @@ public LavalinkPlayer(IPlayerProperties p _systemClock = properties.SystemClock; _logger = properties.Logger; _syncedAt = properties.SystemClock.UtcNow; - _playerLifecycle = properties.Lifecycle; - _voiceServerInterceptor = properties.VoiceServerInterceptor; _unstretchedRelativePosition = default; _connectedOnce = false; @@ -658,10 +655,6 @@ protected virtual async ValueTask NotifyVoiceServerUpdatedAsync(VoiceServer voic return; } - voiceServer = await _voiceServerInterceptor - .InterceptAsync(GuildId, voiceServer, cancellationToken) - .ConfigureAwait(false); - VoiceServer = voiceServer; await UpdateVoiceCredentialsAsync(cancellationToken).ConfigureAwait(false); } diff --git a/src/Lavalink4NET/Players/LavalinkPlayerHandle.cs b/src/Lavalink4NET/Players/LavalinkPlayerHandle.cs index 423f00e6..40515e62 100644 --- a/src/Lavalink4NET/Players/LavalinkPlayerHandle.cs +++ b/src/Lavalink4NET/Players/LavalinkPlayerHandle.cs @@ -96,7 +96,9 @@ public async ValueTask UpdateVoiceServerAsync(VoiceServer voiceServer, Cancellat return; } - _voiceServer = voiceServer; + _voiceServer = await _playerContext.VoiceServerInterceptor + .InterceptAsync(_guildId, voiceServer, cancellationToken) + .ConfigureAwait(false); if (_voiceState is not null) { From 72de0c5916fa4caade7c7cded10b0873d8f1cb56 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 01:26:45 +0200 Subject: [PATCH 03/19] feat: Add initial bootstrapping for voice server interception --- .../Extensions/ServiceCollectionExtensions.cs | 17 ++++ .../IVoiceServerSessionManager.cs | 10 ++ .../Lavalink4NET.Experiments.Receive.csproj | 14 +++ .../LavalinkReceiveVoiceServerInterceptor.cs | 40 ++++++++ .../LavalinkVoiceServer.cs | 98 +++++++++++++++++++ .../Program.cs | 2 + .../VoiceServerSessionManager.cs | 38 +++++++ src/Lavalink4NET.sln | 14 ++- 8 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/IVoiceServerSessionManager.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Lavalink4NET.Experiments.Receive.csproj create mode 100644 experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Program.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/VoiceServerSessionManager.cs diff --git a/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs b/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..eab20b23 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +namespace Lavalink4NET.Experiments.Receive.Extensions; + +using Lavalink4NET.Players; +using Microsoft.Extensions.DependencyInjection.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddLavalinkReceive(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(); + services.Replace(ServiceDescriptor.Singleton()); + + return services; + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/IVoiceServerSessionManager.cs b/experiments/Lavalink4NET.Experiments.Receive/IVoiceServerSessionManager.cs new file mode 100644 index 00000000..c6e25803 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/IVoiceServerSessionManager.cs @@ -0,0 +1,10 @@ +namespace Lavalink4NET.Experiments.Receive; + +using Lavalink4NET.Clients; + +interface IVoiceServerSessionManager +{ + Guid Allocate(ulong guildId, VoiceServer voiceServer); + + bool TryResolve(Guid sessionId, out ulong guildId, out VoiceServer voiceServer); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Lavalink4NET.Experiments.Receive.csproj b/experiments/Lavalink4NET.Experiments.Receive/Lavalink4NET.Experiments.Receive.csproj new file mode 100644 index 00000000..adc3ba7b --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Lavalink4NET.Experiments.Receive.csproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs new file mode 100644 index 00000000..55066cb3 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs @@ -0,0 +1,40 @@ +namespace Lavalink4NET.Experiments.Receive; + +using System.Threading; +using System.Threading.Tasks; +using Lavalink4NET.Clients; +using Lavalink4NET.Players; + +internal sealed class LavalinkReceiveVoiceServerInterceptor : ILavalinkVoiceServerInterceptor +{ + private readonly IVoiceServerSessionManager _sessionManager; + private readonly ILogger _logger; + + public LavalinkReceiveVoiceServerInterceptor( + IVoiceServerSessionManager sessionManager, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(sessionManager); + ArgumentNullException.ThrowIfNull(logger); + + _sessionManager = sessionManager; + _logger = logger; + } + + public ValueTask InterceptAsync( + ulong guildId, + VoiceServer voiceServer, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var sessionToken = _sessionManager.Allocate(guildId, voiceServer); + var proxiedVoiceServer = new VoiceServer(sessionToken.ToString("N"), "localhost:16389"); + + _logger.LogInformation( + "Mapping voice server '{OriginalEndpoint}' ({OriginalToken}) to '{ProxiedEndpoint}' ({ProxiedToken})", + voiceServer.Endpoint, voiceServer.Token, proxiedVoiceServer.Endpoint, proxiedVoiceServer.Token); + + return new ValueTask(proxiedVoiceServer); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs new file mode 100644 index 00000000..cccfae69 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs @@ -0,0 +1,98 @@ +namespace Lavalink4NET.Experiments.Receive; + +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets; +using Microsoft.AspNetCore.WebSockets; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +internal sealed class LavalinkVoiceServer : IHttpApplication +{ + private readonly IVoiceServerSessionManager _serverSessionManager; + private readonly KestrelServer _kestrelServer; + private readonly WebSocketMiddleware _webSocketMiddleware; + + public LavalinkVoiceServer( + IVoiceServerSessionManager serverSessionManager, + ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(serverSessionManager); + + _serverSessionManager = serverSessionManager; + + var kestrelServerOptions = new KestrelServerOptions { AddServerHeader = false, }; + kestrelServerOptions.ListenLocalhost(16389); + + var socketTransportOptions = new SocketTransportOptions { Backlog = 4, }; + var socketTransportFactory = new SocketTransportFactory(Options.Create(socketTransportOptions), loggerFactory); + + _kestrelServer = new KestrelServer( + options: Options.Create(kestrelServerOptions), + transportFactory: socketTransportFactory, + loggerFactory: loggerFactory); + + var webSocketOptions = new WebSocketOptions { }; + + _webSocketMiddleware = new WebSocketMiddleware( + next: ProcessRequestInternalAsync, + options: Options.Create(webSocketOptions), + loggerFactory: loggerFactory); + } + + public async ValueTask StartAsync(CancellationToken cancellationToken = default) + { + await _kestrelServer + .StartAsync(this, cancellationToken) + .ConfigureAwait(false); + } + + public async ValueTask StopAsync(CancellationToken cancellationToken = default) + { + await _kestrelServer + .StopAsync(cancellationToken) + .ConfigureAwait(false); + } + + HttpContext IHttpApplication.CreateContext(IFeatureCollection contextFeatures) + { + ArgumentNullException.ThrowIfNull(contextFeatures); + + return new DefaultHttpContext(contextFeatures); + } + + void IHttpApplication.DisposeContext(HttpContext context, Exception? exception) + { + } + + Task IHttpApplication.ProcessRequestAsync(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return _webSocketMiddleware.Invoke(context); + } + + private async Task ProcessRequestInternalAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + var cancellationToken = httpContext.RequestAborted; + cancellationToken.ThrowIfCancellationRequested(); + + if (!httpContext.WebSockets.IsWebSocketRequest) + { + httpContext.Response.StatusCode = StatusCodes.Status426UpgradeRequired; + httpContext.Response.Headers[HeaderNames.Upgrade] = "websocket"; + httpContext.Response.Headers[HeaderNames.Connection] = "Upgrade"; + + return; + } + + var webSocketAcceptContext = new WebSocketAcceptContext { }; + + var webSocket = await httpContext.WebSockets + .AcceptWebSocketAsync(webSocketAcceptContext) + .ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Program.cs b/experiments/Lavalink4NET.Experiments.Receive/Program.cs new file mode 100644 index 00000000..3751555c --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Program.cs @@ -0,0 +1,2 @@ +// See https://aka.ms/new-console-template for more information +Console.WriteLine("Hello, World!"); diff --git a/experiments/Lavalink4NET.Experiments.Receive/VoiceServerSessionManager.cs b/experiments/Lavalink4NET.Experiments.Receive/VoiceServerSessionManager.cs new file mode 100644 index 00000000..32d26230 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/VoiceServerSessionManager.cs @@ -0,0 +1,38 @@ +namespace Lavalink4NET.Experiments.Receive; + +using System.Collections.Concurrent; +using Lavalink4NET.Clients; + +internal sealed class VoiceServerSessionManager : IVoiceServerSessionManager +{ + private readonly ConcurrentDictionary _voiceServers; + + public VoiceServerSessionManager() + { + _voiceServers = new ConcurrentDictionary(); + } + + public Guid Allocate(ulong guildId, VoiceServer voiceServer) + { + var sessionId = Guid.NewGuid(); + _voiceServers.TryAdd(sessionId, (guildId, voiceServer)); + + return sessionId; + } + + public bool TryResolve(Guid sessionId, out ulong guildId, out VoiceServer voiceServer) + { + if (_voiceServers.TryGetValue(sessionId, out var pair)) + { + guildId = pair.GuildId; + voiceServer = pair.VoiceServer; + + return true; + } + + guildId = default; + voiceServer = default; + + return false; + } +} \ No newline at end of file diff --git a/src/Lavalink4NET.sln b/src/Lavalink4NET.sln index 4aab41d1..7853d93a 100644 --- a/src/Lavalink4NET.sln +++ b/src/Lavalink4NET.sln @@ -88,13 +88,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Integrations.L EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Integrations.Lavasrc.Tests", "..\tests\Lavalink4NET.Integrations.Lavasrc.Tests\Lavalink4NET.Integrations.Lavasrc.Tests.csproj", "{5779F765-5F0D-422C-984A-7E44EAE737C8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.NetCord", "Lavalink4NET.NetCord\Lavalink4NET.NetCord.csproj", "{8587F98B-CFE1-4559-9614-ED3B2B0C4F4E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.NetCord", "Lavalink4NET.NetCord\Lavalink4NET.NetCord.csproj", "{8587F98B-CFE1-4559-9614-ED3B2B0C4F4E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.Samples.NetCord", "..\samples\Lavalink4NET.Samples.NetCord\Lavalink4NET.Samples.NetCord.csproj", "{02FE863F-D979-439A-9A51-C4EA69D58D29}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Samples.NetCord", "..\samples\Lavalink4NET.Samples.NetCord\Lavalink4NET.Samples.NetCord.csproj", "{02FE863F-D979-439A-9A51-C4EA69D58D29}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.Integrations.LyricsJava", "Lavalink4NET.Integrations.LyricsJava\Lavalink4NET.Integrations.LyricsJava.csproj", "{9A30E985-6D67-41D4-A12F-F1ADCD2ED0FE}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Integrations.LyricsJava", "Lavalink4NET.Integrations.LyricsJava\Lavalink4NET.Integrations.LyricsJava.csproj", "{9A30E985-6D67-41D4-A12F-F1ADCD2ED0FE}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.Integrations.LyricsJava.Tests", "Lavalink4NET.Integrations.LyricsJava.Tests\Lavalink4NET.Integrations.LyricsJava.Tests.csproj", "{176B0345-DF57-42B4-A8FD-4E6436D9554C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Lavalink4NET.Integrations.LyricsJava.Tests", "Lavalink4NET.Integrations.LyricsJava.Tests\Lavalink4NET.Integrations.LyricsJava.Tests.csproj", "{176B0345-DF57-42B4-A8FD-4E6436D9554C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lavalink4NET.Experiments.Receive", "..\experiments\Lavalink4NET.Experiments.Receive\Lavalink4NET.Experiments.Receive.csproj", "{F7EC54EF-8C23-4586-BC86-D9B7B702CE14}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -258,6 +260,10 @@ Global {176B0345-DF57-42B4-A8FD-4E6436D9554C}.Debug|Any CPU.Build.0 = Debug|Any CPU {176B0345-DF57-42B4-A8FD-4E6436D9554C}.Release|Any CPU.ActiveCfg = Release|Any CPU {176B0345-DF57-42B4-A8FD-4E6436D9554C}.Release|Any CPU.Build.0 = Release|Any CPU + {F7EC54EF-8C23-4586-BC86-D9B7B702CE14}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F7EC54EF-8C23-4586-BC86-D9B7B702CE14}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F7EC54EF-8C23-4586-BC86-D9B7B702CE14}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F7EC54EF-8C23-4586-BC86-D9B7B702CE14}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 3ac9aea001e9cf05f6e8425a4f32b1749fd32646 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 02:01:11 +0200 Subject: [PATCH 04/19] feat: Expose RemoteLavalinkException --- src/Lavalink4NET.Rest/LavalinkApiClient.cs | 13 ------------- src/Lavalink4NET.Rest/RemoteLavalinkException.cs | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 13 deletions(-) create mode 100644 src/Lavalink4NET.Rest/RemoteLavalinkException.cs diff --git a/src/Lavalink4NET.Rest/LavalinkApiClient.cs b/src/Lavalink4NET.Rest/LavalinkApiClient.cs index 6cb5d5fc..5317cc53 100644 --- a/src/Lavalink4NET.Rest/LavalinkApiClient.cs +++ b/src/Lavalink4NET.Rest/LavalinkApiClient.cs @@ -423,19 +423,6 @@ internal static async ValueTask EnsureSuccessStatusCodeAsync(HttpResponseMessage } } -internal sealed class RemoteLavalinkException : Exception -{ - private readonly string? _stackTrace; - - public RemoteLavalinkException(string? message, string? stackTrace) - : base(message) - { - _stackTrace = stackTrace; - } - - public override string? StackTrace => _stackTrace ?? base.StackTrace; -} - internal static class StrictSearchHelper { public static string Process(StrictSearchBehavior searchBehavior, string identifier, TrackSearchMode searchMode) => searchBehavior switch diff --git a/src/Lavalink4NET.Rest/RemoteLavalinkException.cs b/src/Lavalink4NET.Rest/RemoteLavalinkException.cs new file mode 100644 index 00000000..260280a0 --- /dev/null +++ b/src/Lavalink4NET.Rest/RemoteLavalinkException.cs @@ -0,0 +1,14 @@ +namespace Lavalink4NET.Rest; + +public sealed class RemoteLavalinkException : Exception +{ + private readonly string? _stackTrace; + + public RemoteLavalinkException(string? message, string? stackTrace) + : base(message) + { + _stackTrace = stackTrace; + } + + public override string? StackTrace => _stackTrace ?? base.StackTrace; +} From f4da33ec133af66026bc375a3b3db764986b9cb5 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 02:01:43 +0200 Subject: [PATCH 05/19] feat: Implement basic WebSocket handling --- .../Extensions/ServiceCollectionExtensions.cs | 4 ++ .../ILavalinkVoiceServer.cs | 11 ++++ .../LavalinkKestrelWebHostBuilder.cs | 41 ++++++++++++ .../LavalinkMeterFactory.cs | 18 ++++++ .../LavalinkVoiceServer.cs | 64 ++++++++++++++----- .../LavalinkVoiceServerHost.cs | 30 +++++++++ .../LavalinkWebHostEnvironment.cs | 31 +++++++++ 7 files changed, 184 insertions(+), 15 deletions(-) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/ILavalinkVoiceServer.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/LavalinkKestrelWebHostBuilder.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/LavalinkMeterFactory.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerHost.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/LavalinkWebHostEnvironment.cs diff --git a/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs b/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs index eab20b23..f3df2f9a 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs @@ -10,6 +10,10 @@ public static IServiceCollection AddLavalinkReceive(this IServiceCollection serv ArgumentNullException.ThrowIfNull(services); services.TryAddSingleton(); + services.TryAddSingleton(); + + services.AddHostedService(); + services.Replace(ServiceDescriptor.Singleton()); return services; diff --git a/experiments/Lavalink4NET.Experiments.Receive/ILavalinkVoiceServer.cs b/experiments/Lavalink4NET.Experiments.Receive/ILavalinkVoiceServer.cs new file mode 100644 index 00000000..aebd6f60 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/ILavalinkVoiceServer.cs @@ -0,0 +1,11 @@ +namespace Lavalink4NET.Experiments.Receive; + +using System.Threading; +using System.Threading.Tasks; + +public interface ILavalinkVoiceServer +{ + ValueTask StartAsync(CancellationToken cancellationToken = default); + + ValueTask StopAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkKestrelWebHostBuilder.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkKestrelWebHostBuilder.cs new file mode 100644 index 00000000..c15e943f --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/LavalinkKestrelWebHostBuilder.cs @@ -0,0 +1,41 @@ +namespace Lavalink4NET.Experiments.Receive; + +using System; +using Microsoft.Extensions.DependencyInjection; + +internal sealed class LavalinkKestrelWebHostBuilder : IWebHostBuilder +{ + private readonly IServiceCollection _serviceCollection; + + public LavalinkKestrelWebHostBuilder(IServiceCollection serviceCollection) + { + ArgumentNullException.ThrowIfNull(serviceCollection); + + _serviceCollection = serviceCollection; + } + + IWebHostBuilder IWebHostBuilder.ConfigureServices(Action configureServices) + { + configureServices(_serviceCollection); + return this; + } + + + IWebHost IWebHostBuilder.Build() => throw new NotImplementedException(); + + IWebHostBuilder IWebHostBuilder.ConfigureAppConfiguration(Action configureDelegate) + { + configureDelegate(new WebHostBuilderContext(), new ConfigurationBuilder()); + return this; + } + + IWebHostBuilder IWebHostBuilder.ConfigureServices(Action configureServices) + { + configureServices(new WebHostBuilderContext(), _serviceCollection); + return this; + } + + string IWebHostBuilder.GetSetting(string key) => throw new NotImplementedException(); + + IWebHostBuilder IWebHostBuilder.UseSetting(string key, string? value) => throw new NotImplementedException(); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkMeterFactory.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkMeterFactory.cs new file mode 100644 index 00000000..417a7315 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/LavalinkMeterFactory.cs @@ -0,0 +1,18 @@ +namespace Lavalink4NET.Experiments.Receive; + +using System; +using System.Diagnostics.Metrics; + +internal sealed class LavalinkMeterFactory : IMeterFactory +{ + public Meter Create(MeterOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return new(options); + } + + public void Dispose() + { + } +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs index cccfae69..24d2037b 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs @@ -1,17 +1,20 @@ namespace Lavalink4NET.Experiments.Receive; +using System; +using System.Diagnostics.Metrics; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets; using Microsoft.AspNetCore.WebSockets; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; -internal sealed class LavalinkVoiceServer : IHttpApplication +internal sealed class LavalinkVoiceServer : IHttpApplication, ILavalinkVoiceServer { + private readonly IServiceProvider _serviceProvider; private readonly IVoiceServerSessionManager _serverSessionManager; - private readonly KestrelServer _kestrelServer; + private readonly IServer _server; private readonly WebSocketMiddleware _webSocketMiddleware; public LavalinkVoiceServer( @@ -22,16 +25,22 @@ public LavalinkVoiceServer( _serverSessionManager = serverSessionManager; - var kestrelServerOptions = new KestrelServerOptions { AddServerHeader = false, }; - kestrelServerOptions.ListenLocalhost(16389); + var services = new ServiceCollection(); - var socketTransportOptions = new SocketTransportOptions { Backlog = 4, }; - var socketTransportFactory = new SocketTransportFactory(Options.Create(socketTransportOptions), loggerFactory); + // HTTP Kestrel Host + services.TryAddSingleton(); + services.TryAddSingleton(); - _kestrelServer = new KestrelServer( - options: Options.Create(kestrelServerOptions), - transportFactory: socketTransportFactory, - loggerFactory: loggerFactory); + // Logging + services.TryAddSingleton(loggerFactory); + services.TryAdd(ServiceDescriptor.Singleton(typeof(ILogger<>), typeof(Logger<>))); + + var builder = new LavalinkKestrelWebHostBuilder(services); + + builder.UseKestrel((context, options) => + { + options.ListenLocalhost(16389, x => x.UseHttps()); + }); var webSocketOptions = new WebSocketOptions { }; @@ -39,18 +48,21 @@ public LavalinkVoiceServer( next: ProcessRequestInternalAsync, options: Options.Create(webSocketOptions), loggerFactory: loggerFactory); + + _serviceProvider = services.BuildServiceProvider(); + _server = _serviceProvider.GetRequiredService(); } public async ValueTask StartAsync(CancellationToken cancellationToken = default) { - await _kestrelServer + await _server .StartAsync(this, cancellationToken) .ConfigureAwait(false); } public async ValueTask StopAsync(CancellationToken cancellationToken = default) { - await _kestrelServer + await _server .StopAsync(cancellationToken) .ConfigureAwait(false); } @@ -77,6 +89,28 @@ private async Task ProcessRequestInternalAsync(HttpContext httpContext) { ArgumentNullException.ThrowIfNull(httpContext); + if (httpContext.Request.Path.Equals("/")) + { + httpContext.Response.StatusCode = StatusCodes.Status200OK; + + await httpContext.Response + .WriteAsync("Lavalink4NET Voice Proxy Server") + .ConfigureAwait(false); + + return; + } + + if (!httpContext.Request.Path.Equals("/voice")) + { + httpContext.Response.StatusCode = StatusCodes.Status404NotFound; + + await httpContext.Response + .WriteAsync("Not Found") + .ConfigureAwait(false); + + return; + } + var cancellationToken = httpContext.RequestAborted; cancellationToken.ThrowIfCancellationRequested(); @@ -95,4 +129,4 @@ private async Task ProcessRequestInternalAsync(HttpContext httpContext) .AcceptWebSocketAsync(webSocketAcceptContext) .ConfigureAwait(false); } -} \ No newline at end of file +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerHost.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerHost.cs new file mode 100644 index 00000000..50c21f39 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerHost.cs @@ -0,0 +1,30 @@ +namespace Lavalink4NET.Experiments.Receive; + +using System.Threading; +using System.Threading.Tasks; + +internal sealed class LavalinkVoiceServerHost : IHostedService +{ + private readonly ILavalinkVoiceServer _lavalinkVoiceServer; + + public LavalinkVoiceServerHost(ILavalinkVoiceServer lavalinkVoiceServer) + { + ArgumentNullException.ThrowIfNull(lavalinkVoiceServer); + + _lavalinkVoiceServer = lavalinkVoiceServer; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + return _lavalinkVoiceServer.StartAsync(cancellationToken).AsTask(); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + return _lavalinkVoiceServer.StopAsync(cancellationToken).AsTask(); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkWebHostEnvironment.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkWebHostEnvironment.cs new file mode 100644 index 00000000..c3aa46bf --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/LavalinkWebHostEnvironment.cs @@ -0,0 +1,31 @@ +namespace Lavalink4NET.Experiments.Receive; + +using System; +using Microsoft.Extensions.FileProviders; + +internal sealed class LavalinkWebHostEnvironment : IHostEnvironment +{ + public string EnvironmentName + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public string ApplicationName + { + get => "Lavalink"; + set => throw new NotImplementedException(); + } + + public string ContentRootPath + { + get => Directory.GetCurrentDirectory(); + set => throw new NotImplementedException(); + } + + public IFileProvider ContentRootFileProvider + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } +} From fc24a571470172669d798d4dde25928fd90be874 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 02:24:31 +0200 Subject: [PATCH 06/19] feat: Allow to configure lavalink voice server endpoint --- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../LavalinkReceiveVoiceServerInterceptor.cs | 7 ++++++- .../LavalinkVoiceServer.cs | 7 ++++--- .../LavalinkVoiceServerOptions.cs | 11 +++++++++++ 4 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerOptions.cs diff --git a/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs b/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs index f3df2f9a..9a281145 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ public static IServiceCollection AddLavalinkReceive(this IServiceCollection serv services.TryAddSingleton(); services.TryAddSingleton(); + services.Configure(static _ => { }); services.AddHostedService(); diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs index 55066cb3..22b3cc1c 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs @@ -4,20 +4,25 @@ using System.Threading.Tasks; using Lavalink4NET.Clients; using Lavalink4NET.Players; +using Microsoft.Extensions.Options; internal sealed class LavalinkReceiveVoiceServerInterceptor : ILavalinkVoiceServerInterceptor { private readonly IVoiceServerSessionManager _sessionManager; + private readonly LavalinkVoiceServerOptions _options; private readonly ILogger _logger; public LavalinkReceiveVoiceServerInterceptor( IVoiceServerSessionManager sessionManager, + IOptions options, ILogger logger) { ArgumentNullException.ThrowIfNull(sessionManager); + ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(logger); _sessionManager = sessionManager; + _options = options.Value; _logger = logger; } @@ -29,7 +34,7 @@ public ValueTask InterceptAsync( cancellationToken.ThrowIfCancellationRequested(); var sessionToken = _sessionManager.Allocate(guildId, voiceServer); - var proxiedVoiceServer = new VoiceServer(sessionToken.ToString("N"), "localhost:16389"); + var proxiedVoiceServer = new VoiceServer(sessionToken.ToString("N"), $"{_options.BindAddress}:{_options.Port}"); _logger.LogInformation( "Mapping voice server '{OriginalEndpoint}' ({OriginalToken}) to '{ProxiedEndpoint}' ({ProxiedToken})", diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs index 24d2037b..e24eb5f8 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs @@ -19,7 +19,8 @@ internal sealed class LavalinkVoiceServer : IHttpApplication, ILava public LavalinkVoiceServer( IVoiceServerSessionManager serverSessionManager, - ILoggerFactory loggerFactory) + ILoggerFactory loggerFactory, + IOptions options) { ArgumentNullException.ThrowIfNull(serverSessionManager); @@ -37,9 +38,9 @@ public LavalinkVoiceServer( var builder = new LavalinkKestrelWebHostBuilder(services); - builder.UseKestrel((context, options) => + builder.UseKestrel((context, serverOptions) => { - options.ListenLocalhost(16389, x => x.UseHttps()); + serverOptions.Listen(options.Value.BindAddress, options.Value.Port, x => x.UseHttps()); }); var webSocketOptions = new WebSocketOptions { }; diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerOptions.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerOptions.cs new file mode 100644 index 00000000..7a95d244 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerOptions.cs @@ -0,0 +1,11 @@ +namespace Lavalink4NET.Experiments.Receive; + +using System.Net; +using System.Net.Sockets; + +public sealed record class LavalinkVoiceServerOptions +{ + public int Port { get; set; } = 16389; + + public IPAddress BindAddress { get; set; } = Socket.OSSupportsIPv6 ? IPAddress.IPv6Loopback : IPAddress.Loopback; +} From 10d39eba8fb83e89740c688d78cfb7fd40553483 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 02:56:38 +0200 Subject: [PATCH 07/19] feat: Implement identify --- .../Connections/IVoiceConnectionHandler.cs | 10 +++ .../Connections/IVoiceProtocolHandler.cs | 11 +++ .../Converters/PayloadJsonConverter.cs | 83 +++++++++++++++++++ .../Converters/SnowflakeJsonConverter.cs | 19 +++++ .../Connections/Payloads/IVoicePayload.cs | 9 ++ .../Connections/Payloads/IdentifyPayload.cs | 25 ++++++ .../Payloads/PayloadJsonSerializerContext.cs | 9 ++ .../Connections/VoiceConnectionHandler.cs | 47 +++++++++++ .../Connections/VoiceProtocolHandler.cs | 41 +++++++++ .../Extensions/ServiceCollectionExtensions.cs | 7 ++ .../LavalinkReceiveVoiceServerInterceptor.cs | 4 +- .../LavalinkVoiceServerOptions.cs | 11 --- .../{ => Server}/ILavalinkVoiceServer.cs | 2 +- .../LavalinkKestrelWebHostBuilder.cs | 2 +- .../{ => Server}/LavalinkMeterFactory.cs | 2 +- .../{ => Server}/LavalinkVoiceServer.cs | 30 +++---- .../{ => Server}/LavalinkVoiceServerHost.cs | 2 +- .../Server/LavalinkVoiceServerOptions.cs | 6 ++ .../LavalinkWebHostEnvironment.cs | 2 +- .../IVoiceServerSessionManager.cs | 2 +- .../VoiceServerSessionManager.cs | 4 +- 21 files changed, 290 insertions(+), 38 deletions(-) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SnowflakeJsonConverter.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IVoicePayload.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IdentifyPayload.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs delete mode 100644 experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerOptions.cs rename experiments/Lavalink4NET.Experiments.Receive/{ => Server}/ILavalinkVoiceServer.cs (81%) rename experiments/Lavalink4NET.Experiments.Receive/{ => Server}/LavalinkKestrelWebHostBuilder.cs (96%) rename experiments/Lavalink4NET.Experiments.Receive/{ => Server}/LavalinkMeterFactory.cs (84%) rename experiments/Lavalink4NET.Experiments.Receive/{ => Server}/LavalinkVoiceServer.cs (83%) rename experiments/Lavalink4NET.Experiments.Receive/{ => Server}/LavalinkVoiceServerHost.cs (93%) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerOptions.cs rename experiments/Lavalink4NET.Experiments.Receive/{ => Server}/LavalinkWebHostEnvironment.cs (93%) rename experiments/Lavalink4NET.Experiments.Receive/{ => Sessions}/IVoiceServerSessionManager.cs (79%) rename experiments/Lavalink4NET.Experiments.Receive/{ => Sessions}/VoiceServerSessionManager.cs (88%) diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs new file mode 100644 index 00000000..d53e4e32 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs @@ -0,0 +1,10 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Net.WebSockets; + +public interface IVoiceConnectionHandler +{ + ValueTask ProcessAsync( + WebSocket webSocket, + CancellationToken cancellationToken = default); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs new file mode 100644 index 00000000..aa70a131 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs @@ -0,0 +1,11 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Net.WebSockets; +using Lavalink4NET.Experiments.Receive.Connections.Payloads; + +internal interface IVoiceProtocolHandler +{ + ValueTask ReadAsync( + WebSocket webSocket, + CancellationToken cancellationToken = default); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs new file mode 100644 index 00000000..0b00a4f8 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs @@ -0,0 +1,83 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; + +internal sealed class PayloadJsonConverter : JsonConverter +{ + public override IVoicePayload? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected the start of an object."); + } + + if (!reader.Read() || reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected a property name."); + } + + var propertyName = reader.GetString(); + + if (propertyName != "op") + { + throw new JsonException("Expected the 'op' property."); + } + + if (!reader.Read() || reader.TokenType != JsonTokenType.Number) + { + throw new JsonException("Expected a number."); + } + + var op = reader.GetInt32(); + + if (!reader.Read() || reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected a property name."); + } + + propertyName = reader.GetString(); + + if (propertyName != "d") + { + throw new JsonException("Expected the 'd' property."); + } + + if (!reader.Read()) + { + throw new JsonException("Expected a value."); + } + + var payload = op switch + { + 0 => JsonSerializer.Deserialize(ref reader, options), + _ => throw new JsonException($"Unknown operation code: {op}.") + }; + + if (!reader.Read() || reader.TokenType != JsonTokenType.EndObject) + { + throw new JsonException("Expected the end of an object."); + } + + return payload; + } + + public override void Write(Utf8JsonWriter writer, IVoicePayload value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + var opCode = value switch + { + IdentifyPayload _ => 0, + _ => throw new JsonException("Unknown payload type.") + }; + + writer.WriteNumber("op", opCode); + writer.WritePropertyName("d"); + + JsonSerializer.Serialize(writer, value, value.GetType(), options); + + writer.WriteEndObject(); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SnowflakeJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SnowflakeJsonConverter.cs new file mode 100644 index 00000000..84cf9bdf --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SnowflakeJsonConverter.cs @@ -0,0 +1,19 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +internal sealed class SnowflakeJsonConverter : JsonConverter +{ + public override ulong Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return ulong.Parse(reader.GetString()!, CultureInfo.InvariantCulture); + } + + public override void Write(Utf8JsonWriter writer, ulong value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture)); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IVoicePayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IVoicePayload.cs new file mode 100644 index 00000000..5d8f74ac --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IVoicePayload.cs @@ -0,0 +1,9 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; +using Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +[JsonConverter(typeof(PayloadJsonConverter))] +internal interface IVoicePayload +{ +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IdentifyPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IdentifyPayload.cs new file mode 100644 index 00000000..e16c51b8 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/IdentifyPayload.cs @@ -0,0 +1,25 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; +using Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +internal sealed record class IdentifyPayload : IVoicePayload +{ + [JsonRequired] + [JsonPropertyName("server_id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public required ulong GuildId { get; set; } + + [JsonRequired] + [JsonPropertyName("user_id")] + [JsonConverter(typeof(SnowflakeJsonConverter))] + public required ulong UserId { get; set; } + + [JsonRequired] + [JsonPropertyName("session_id")] + public required string SessionId { get; set; } + + [JsonRequired] + [JsonPropertyName("token")] + public required string Token { get; set; } +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs new file mode 100644 index 00000000..996c367a --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs @@ -0,0 +1,9 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; + +[JsonSerializable(typeof(IVoicePayload))] +[JsonSerializable(typeof(IdentifyPayload))] +internal sealed partial class PayloadJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs new file mode 100644 index 00000000..336c9d9c --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs @@ -0,0 +1,47 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using Lavalink4NET.Experiments.Receive.Connections.Payloads; +using Lavalink4NET.Experiments.Receive.Sessions; + +internal sealed class VoiceConnectionHandler : IVoiceConnectionHandler +{ + private readonly IVoiceServerSessionManager _sessionManager; + private readonly IVoiceProtocolHandler _protocolHandler; + + public VoiceConnectionHandler( + IVoiceServerSessionManager sessionManager, + IVoiceProtocolHandler protocolHandler) + { + ArgumentNullException.ThrowIfNull(sessionManager); + ArgumentNullException.ThrowIfNull(protocolHandler); + + _sessionManager = sessionManager; + _protocolHandler = protocolHandler; + } + + public async ValueTask ProcessAsync(WebSocket webSocket, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(webSocket); + + var payload = await _protocolHandler + .ReadAsync(webSocket, cancellationToken) + .ConfigureAwait(false); + + if (payload is not IdentifyPayload identifyPayload) + { + throw new WebSocketException("Expected identify payload."); + } + + if (!Guid.TryParseExact(identifyPayload.Token, "N", out var token) || + !_sessionManager.TryResolve(token, out var guildId, out var voiceServer)) + { + throw new WebSocketException("Invalid session id."); + } + + ; + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs new file mode 100644 index 00000000..e0a8652d --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs @@ -0,0 +1,41 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Buffers; +using System.Net.WebSockets; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Lavalink4NET.Experiments.Receive.Connections.Payloads; + +internal sealed class VoiceProtocolHandler : IVoiceProtocolHandler +{ + public async ValueTask ReadAsync(WebSocket webSocket, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(webSocket); + + var pooledBuffer = ArrayPool.Shared.Rent(64 * 1024); + + try + { + var buffer = new Memory(pooledBuffer); + + var result = await webSocket + .ReceiveAsync(buffer, cancellationToken) + .ConfigureAwait(false); + + if (result.MessageType == WebSocketMessageType.Close) + { + throw new WebSocketException("WebSocket connection is closing."); + } + + var payloadBuffer = buffer[..result.Count]; + + return JsonSerializer.Deserialize(payloadBuffer.Span, PayloadJsonSerializerContext.Default.IVoicePayload)!; + } + finally + { + ArrayPool.Shared.Return(pooledBuffer); + } + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs b/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs index 9a281145..3e4753cb 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,8 @@ namespace Lavalink4NET.Experiments.Receive.Extensions; +using Lavalink4NET.Experiments.Receive.Connections; +using Lavalink4NET.Experiments.Receive.Server; +using Lavalink4NET.Experiments.Receive.Sessions; using Lavalink4NET.Players; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -10,7 +13,11 @@ public static IServiceCollection AddLavalinkReceive(this IServiceCollection serv ArgumentNullException.ThrowIfNull(services); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.Configure(static _ => { }); services.AddHostedService(); diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs index 22b3cc1c..985ed1cf 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/LavalinkReceiveVoiceServerInterceptor.cs @@ -3,6 +3,8 @@ using System.Threading; using System.Threading.Tasks; using Lavalink4NET.Clients; +using Lavalink4NET.Experiments.Receive.Server; +using Lavalink4NET.Experiments.Receive.Sessions; using Lavalink4NET.Players; using Microsoft.Extensions.Options; @@ -34,7 +36,7 @@ public ValueTask InterceptAsync( cancellationToken.ThrowIfCancellationRequested(); var sessionToken = _sessionManager.Allocate(guildId, voiceServer); - var proxiedVoiceServer = new VoiceServer(sessionToken.ToString("N"), $"{_options.BindAddress}:{_options.Port}"); + var proxiedVoiceServer = new VoiceServer(sessionToken.ToString("N"), $"localhost:{_options.Port}"); _logger.LogInformation( "Mapping voice server '{OriginalEndpoint}' ({OriginalToken}) to '{ProxiedEndpoint}' ({ProxiedToken})", diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerOptions.cs b/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerOptions.cs deleted file mode 100644 index 7a95d244..00000000 --- a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerOptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Lavalink4NET.Experiments.Receive; - -using System.Net; -using System.Net.Sockets; - -public sealed record class LavalinkVoiceServerOptions -{ - public int Port { get; set; } = 16389; - - public IPAddress BindAddress { get; set; } = Socket.OSSupportsIPv6 ? IPAddress.IPv6Loopback : IPAddress.Loopback; -} diff --git a/experiments/Lavalink4NET.Experiments.Receive/ILavalinkVoiceServer.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/ILavalinkVoiceServer.cs similarity index 81% rename from experiments/Lavalink4NET.Experiments.Receive/ILavalinkVoiceServer.cs rename to experiments/Lavalink4NET.Experiments.Receive/Server/ILavalinkVoiceServer.cs index aebd6f60..fc96bc37 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/ILavalinkVoiceServer.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/ILavalinkVoiceServer.cs @@ -1,4 +1,4 @@ -namespace Lavalink4NET.Experiments.Receive; +namespace Lavalink4NET.Experiments.Receive.Server; using System.Threading; using System.Threading.Tasks; diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkKestrelWebHostBuilder.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkKestrelWebHostBuilder.cs similarity index 96% rename from experiments/Lavalink4NET.Experiments.Receive/LavalinkKestrelWebHostBuilder.cs rename to experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkKestrelWebHostBuilder.cs index c15e943f..11a94502 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/LavalinkKestrelWebHostBuilder.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkKestrelWebHostBuilder.cs @@ -1,4 +1,4 @@ -namespace Lavalink4NET.Experiments.Receive; +namespace Lavalink4NET.Experiments.Receive.Server; using System; using Microsoft.Extensions.DependencyInjection; diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkMeterFactory.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkMeterFactory.cs similarity index 84% rename from experiments/Lavalink4NET.Experiments.Receive/LavalinkMeterFactory.cs rename to experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkMeterFactory.cs index 417a7315..99b1ef47 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/LavalinkMeterFactory.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkMeterFactory.cs @@ -1,4 +1,4 @@ -namespace Lavalink4NET.Experiments.Receive; +namespace Lavalink4NET.Experiments.Receive.Server; using System; using System.Diagnostics.Metrics; diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs similarity index 83% rename from experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs rename to experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs index e24eb5f8..7bc5b3a9 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServer.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs @@ -1,7 +1,8 @@ -namespace Lavalink4NET.Experiments.Receive; +namespace Lavalink4NET.Experiments.Receive.Server; using System; using System.Diagnostics.Metrics; +using Lavalink4NET.Experiments.Receive.Connections; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.WebSockets; @@ -13,18 +14,18 @@ internal sealed class LavalinkVoiceServer : IHttpApplication, ILavalinkVoiceServer { private readonly IServiceProvider _serviceProvider; - private readonly IVoiceServerSessionManager _serverSessionManager; + private readonly IVoiceConnectionHandler _voiceConnectionHandler; private readonly IServer _server; private readonly WebSocketMiddleware _webSocketMiddleware; public LavalinkVoiceServer( - IVoiceServerSessionManager serverSessionManager, + IVoiceConnectionHandler voiceConnectionHandler, ILoggerFactory loggerFactory, IOptions options) { - ArgumentNullException.ThrowIfNull(serverSessionManager); + ArgumentNullException.ThrowIfNull(voiceConnectionHandler); - _serverSessionManager = serverSessionManager; + _voiceConnectionHandler = voiceConnectionHandler; var services = new ServiceCollection(); @@ -40,7 +41,7 @@ public LavalinkVoiceServer( builder.UseKestrel((context, serverOptions) => { - serverOptions.Listen(options.Value.BindAddress, options.Value.Port, x => x.UseHttps()); + serverOptions.ListenLocalhost(options.Value.Port, x => x.UseHttps()); }); var webSocketOptions = new WebSocketOptions { }; @@ -90,7 +91,7 @@ private async Task ProcessRequestInternalAsync(HttpContext httpContext) { ArgumentNullException.ThrowIfNull(httpContext); - if (httpContext.Request.Path.Equals("/")) + if (!httpContext.Request.Query.TryGetValue("v", out var versionValue)) { httpContext.Response.StatusCode = StatusCodes.Status200OK; @@ -101,17 +102,6 @@ await httpContext.Response return; } - if (!httpContext.Request.Path.Equals("/voice")) - { - httpContext.Response.StatusCode = StatusCodes.Status404NotFound; - - await httpContext.Response - .WriteAsync("Not Found") - .ConfigureAwait(false); - - return; - } - var cancellationToken = httpContext.RequestAborted; cancellationToken.ThrowIfCancellationRequested(); @@ -129,5 +119,9 @@ await httpContext.Response var webSocket = await httpContext.WebSockets .AcceptWebSocketAsync(webSocketAcceptContext) .ConfigureAwait(false); + + await _voiceConnectionHandler + .ProcessAsync(webSocket, cancellationToken) + .ConfigureAwait(false); } } diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerHost.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerHost.cs similarity index 93% rename from experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerHost.cs rename to experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerHost.cs index 50c21f39..e58ebecd 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/LavalinkVoiceServerHost.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerHost.cs @@ -1,4 +1,4 @@ -namespace Lavalink4NET.Experiments.Receive; +namespace Lavalink4NET.Experiments.Receive.Server; using System.Threading; using System.Threading.Tasks; diff --git a/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerOptions.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerOptions.cs new file mode 100644 index 00000000..cc90a84c --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServerOptions.cs @@ -0,0 +1,6 @@ +namespace Lavalink4NET.Experiments.Receive.Server; + +public sealed record class LavalinkVoiceServerOptions +{ + public int Port { get; set; } = 16389; +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/LavalinkWebHostEnvironment.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkWebHostEnvironment.cs similarity index 93% rename from experiments/Lavalink4NET.Experiments.Receive/LavalinkWebHostEnvironment.cs rename to experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkWebHostEnvironment.cs index c3aa46bf..7514adb4 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/LavalinkWebHostEnvironment.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkWebHostEnvironment.cs @@ -1,4 +1,4 @@ -namespace Lavalink4NET.Experiments.Receive; +namespace Lavalink4NET.Experiments.Receive.Server; using System; using Microsoft.Extensions.FileProviders; diff --git a/experiments/Lavalink4NET.Experiments.Receive/IVoiceServerSessionManager.cs b/experiments/Lavalink4NET.Experiments.Receive/Sessions/IVoiceServerSessionManager.cs similarity index 79% rename from experiments/Lavalink4NET.Experiments.Receive/IVoiceServerSessionManager.cs rename to experiments/Lavalink4NET.Experiments.Receive/Sessions/IVoiceServerSessionManager.cs index c6e25803..23def27a 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/IVoiceServerSessionManager.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Sessions/IVoiceServerSessionManager.cs @@ -1,4 +1,4 @@ -namespace Lavalink4NET.Experiments.Receive; +namespace Lavalink4NET.Experiments.Receive.Sessions; using Lavalink4NET.Clients; diff --git a/experiments/Lavalink4NET.Experiments.Receive/VoiceServerSessionManager.cs b/experiments/Lavalink4NET.Experiments.Receive/Sessions/VoiceServerSessionManager.cs similarity index 88% rename from experiments/Lavalink4NET.Experiments.Receive/VoiceServerSessionManager.cs rename to experiments/Lavalink4NET.Experiments.Receive/Sessions/VoiceServerSessionManager.cs index 32d26230..169c259c 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/VoiceServerSessionManager.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Sessions/VoiceServerSessionManager.cs @@ -1,4 +1,4 @@ -namespace Lavalink4NET.Experiments.Receive; +namespace Lavalink4NET.Experiments.Receive.Sessions; using System.Collections.Concurrent; using Lavalink4NET.Clients; @@ -22,7 +22,7 @@ public Guid Allocate(ulong guildId, VoiceServer voiceServer) public bool TryResolve(Guid sessionId, out ulong guildId, out VoiceServer voiceServer) { - if (_voiceServers.TryGetValue(sessionId, out var pair)) + if (_voiceServers.TryRemove(sessionId, out var pair)) { guildId = pair.GuildId; voiceServer = pair.VoiceServer; From cfc89aeba2193e4b7411c5cedf46738d1190156a Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 03:01:35 +0200 Subject: [PATCH 08/19] feat: Add ready payload --- .../Converters/PayloadJsonConverter.cs | 4 +++- .../Payloads/PayloadJsonSerializerContext.cs | 1 + .../Connections/Payloads/ReadyPayload.cs | 23 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ReadyPayload.cs diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs index 0b00a4f8..e6c1e33b 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs @@ -51,7 +51,8 @@ internal sealed class PayloadJsonConverter : JsonConverter var payload = op switch { - 0 => JsonSerializer.Deserialize(ref reader, options), + 0 => JsonSerializer.Deserialize(ref reader, options) as IVoicePayload, + 2 => JsonSerializer.Deserialize(ref reader, options), _ => throw new JsonException($"Unknown operation code: {op}.") }; @@ -70,6 +71,7 @@ public override void Write(Utf8JsonWriter writer, IVoicePayload value, JsonSeria var opCode = value switch { IdentifyPayload _ => 0, + ReadyPayload _ => 2, _ => throw new JsonException("Unknown payload type.") }; diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs index 996c367a..7b3cfb57 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs @@ -4,6 +4,7 @@ [JsonSerializable(typeof(IVoicePayload))] [JsonSerializable(typeof(IdentifyPayload))] +[JsonSerializable(typeof(ReadyPayload))] internal sealed partial class PayloadJsonSerializerContext : JsonSerializerContext { } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ReadyPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ReadyPayload.cs new file mode 100644 index 00000000..cff04a9a --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ReadyPayload.cs @@ -0,0 +1,23 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Collections.Immutable; +using System.Text.Json.Serialization; + +internal sealed record class ReadyPayload : IVoicePayload +{ + [JsonRequired] + [JsonPropertyName("ssrc")] + public required uint Ssrc { get; set; } + + [JsonRequired] + [JsonPropertyName("ip")] + public required string Ip { get; set; } + + [JsonRequired] + [JsonPropertyName("port")] + public required int Port { get; set; } + + [JsonRequired] + [JsonPropertyName("modes")] + public required ImmutableArray Modes { get; set; } +} From accf7f54b6586fa013cb10ee0c06a4590af74701 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 03:03:11 +0200 Subject: [PATCH 09/19] feat: Implement hello payload --- .../HeartbeatIntervalJsonConverter.cs | 17 +++++++++++++++++ .../Payloads/Converters/PayloadJsonConverter.cs | 2 ++ .../Connections/Payloads/HelloPayload.cs | 12 ++++++++++++ .../Payloads/PayloadJsonSerializerContext.cs | 1 + 4 files changed, 32 insertions(+) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/HeartbeatIntervalJsonConverter.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HelloPayload.cs diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/HeartbeatIntervalJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/HeartbeatIntervalJsonConverter.cs new file mode 100644 index 00000000..3d94e2f5 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/HeartbeatIntervalJsonConverter.cs @@ -0,0 +1,17 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +using System.Text.Json; +using System.Text.Json.Serialization; + +internal sealed class HeartbeatIntervalJsonConverter : JsonConverter +{ + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return TimeSpan.FromMilliseconds(reader.GetUInt64()); + } + + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + { + writer.WriteNumberValue((ulong)value.TotalMilliseconds); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs index e6c1e33b..4b5b0496 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs @@ -53,6 +53,7 @@ internal sealed class PayloadJsonConverter : JsonConverter { 0 => JsonSerializer.Deserialize(ref reader, options) as IVoicePayload, 2 => JsonSerializer.Deserialize(ref reader, options), + 8 => JsonSerializer.Deserialize(ref reader, options), _ => throw new JsonException($"Unknown operation code: {op}.") }; @@ -72,6 +73,7 @@ public override void Write(Utf8JsonWriter writer, IVoicePayload value, JsonSeria { IdentifyPayload _ => 0, ReadyPayload _ => 2, + HelloPayload _ => 8, _ => throw new JsonException("Unknown payload type.") }; diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HelloPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HelloPayload.cs new file mode 100644 index 00000000..37fcdc03 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HelloPayload.cs @@ -0,0 +1,12 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; +using Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +internal sealed record class HelloPayload : IVoicePayload +{ + [JsonRequired] + [JsonConverter(typeof(HeartbeatIntervalJsonConverter))] + [JsonPropertyName("heartbeat_interval")] + public required TimeSpan HeartbeatInterval { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs index 7b3cfb57..7e834d4d 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs @@ -5,6 +5,7 @@ [JsonSerializable(typeof(IVoicePayload))] [JsonSerializable(typeof(IdentifyPayload))] [JsonSerializable(typeof(ReadyPayload))] +[JsonSerializable(typeof(HelloPayload))] internal sealed partial class PayloadJsonSerializerContext : JsonSerializerContext { } From cecc6e822521b3ebbee435236222814528205cf8 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 03:06:38 +0200 Subject: [PATCH 10/19] feat: Add heartbeat (ack) payload --- .../Payloads/Converters/PayloadJsonConverter.cs | 17 ++++++++++++++++- .../Connections/Payloads/HeartbeatAckPayload.cs | 6 ++++++ .../Connections/Payloads/HeartbeatPayload.cs | 6 ++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatAckPayload.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatPayload.cs diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs index 4b5b0496..9f004f2e 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs @@ -53,6 +53,8 @@ internal sealed class PayloadJsonConverter : JsonConverter { 0 => JsonSerializer.Deserialize(ref reader, options) as IVoicePayload, 2 => JsonSerializer.Deserialize(ref reader, options), + 3 => new HeartbeatPayload { SequenceNumber = reader.GetUInt64(), }, + 6 => new HeartbeatAckPayload { SequenceNumber = reader.GetUInt64(), }, 8 => JsonSerializer.Deserialize(ref reader, options), _ => throw new JsonException($"Unknown operation code: {op}.") }; @@ -73,6 +75,8 @@ public override void Write(Utf8JsonWriter writer, IVoicePayload value, JsonSeria { IdentifyPayload _ => 0, ReadyPayload _ => 2, + HeartbeatPayload _ => 3, + HeartbeatAckPayload _ => 6, HelloPayload _ => 8, _ => throw new JsonException("Unknown payload type.") }; @@ -80,7 +84,18 @@ public override void Write(Utf8JsonWriter writer, IVoicePayload value, JsonSeria writer.WriteNumber("op", opCode); writer.WritePropertyName("d"); - JsonSerializer.Serialize(writer, value, value.GetType(), options); + if (value is HeartbeatPayload heartbeatPayload) + { + writer.WriteNumberValue(heartbeatPayload.SequenceNumber); + } + else if (value is HeartbeatAckPayload heartbeatAckPayload) + { + writer.WriteNumberValue(heartbeatAckPayload.SequenceNumber); + } + else + { + JsonSerializer.Serialize(writer, value, value.GetType(), options); + } writer.WriteEndObject(); } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatAckPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatAckPayload.cs new file mode 100644 index 00000000..a8a26645 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatAckPayload.cs @@ -0,0 +1,6 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +public sealed record class HeartbeatAckPayload : IVoicePayload +{ + public ulong SequenceNumber { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatPayload.cs new file mode 100644 index 00000000..d8890fac --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/HeartbeatPayload.cs @@ -0,0 +1,6 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +public sealed record class HeartbeatPayload : IVoicePayload +{ + public ulong SequenceNumber { get; set; } +} From c20e120b2b2a7ccecb2c60fb51a6a87f12b42516 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 03:09:04 +0200 Subject: [PATCH 11/19] feat: Implement select protocol payload --- .../Converters/PayloadJsonConverter.cs | 2 ++ .../Payloads/PayloadJsonSerializerContext.cs | 1 + .../Connections/Payloads/SelectProtocolData.cs | 18 ++++++++++++++++++ .../Payloads/SelectProtocolPayload.cs | 14 ++++++++++++++ 4 files changed, 35 insertions(+) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolData.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolPayload.cs diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs index 9f004f2e..1a18f435 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs @@ -52,6 +52,7 @@ internal sealed class PayloadJsonConverter : JsonConverter var payload = op switch { 0 => JsonSerializer.Deserialize(ref reader, options) as IVoicePayload, + 1 => JsonSerializer.Deserialize(ref reader, options), 2 => JsonSerializer.Deserialize(ref reader, options), 3 => new HeartbeatPayload { SequenceNumber = reader.GetUInt64(), }, 6 => new HeartbeatAckPayload { SequenceNumber = reader.GetUInt64(), }, @@ -74,6 +75,7 @@ public override void Write(Utf8JsonWriter writer, IVoicePayload value, JsonSeria var opCode = value switch { IdentifyPayload _ => 0, + SelectProtocolPayload _ => 1, ReadyPayload _ => 2, HeartbeatPayload _ => 3, HeartbeatAckPayload _ => 6, diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs index 7e834d4d..2f148e01 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs @@ -6,6 +6,7 @@ [JsonSerializable(typeof(IdentifyPayload))] [JsonSerializable(typeof(ReadyPayload))] [JsonSerializable(typeof(HelloPayload))] +[JsonSerializable(typeof(SelectProtocolPayload))] internal sealed partial class PayloadJsonSerializerContext : JsonSerializerContext { } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolData.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolData.cs new file mode 100644 index 00000000..6b8a41a2 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolData.cs @@ -0,0 +1,18 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; + +internal sealed record class SelectProtocolData +{ + [JsonRequired] + [JsonPropertyName("address")] + public required string Address { get; set; } + + [JsonRequired] + [JsonPropertyName("port")] + public required int Port { get; set; } + + [JsonRequired] + [JsonPropertyName("mode")] + public required string Mode { get; set; } +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolPayload.cs new file mode 100644 index 00000000..c18203f9 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SelectProtocolPayload.cs @@ -0,0 +1,14 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; + +internal sealed record class SelectProtocolPayload : IVoicePayload +{ + [JsonRequired] + [JsonPropertyName("protocol")] + public required string Protocol { get; set; } + + [JsonRequired] + [JsonPropertyName("data")] + public required SelectProtocolData Data { get; set; } +} From c6f1e70bce281435e80970abcb1d33b31390c6bf Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 03:12:47 +0200 Subject: [PATCH 12/19] feat: Implement session description payload --- .../Converters/PayloadJsonConverter.cs | 2 ++ .../Converters/SecretKeyJsonConverter.cs | 28 +++++++++++++++++++ .../Payloads/PayloadJsonSerializerContext.cs | 3 ++ .../Payloads/SessionDescriptionPayload.cs | 16 +++++++++++ 4 files changed, 49 insertions(+) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SecretKeyJsonConverter.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SessionDescriptionPayload.cs diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs index 1a18f435..1b7d20c1 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs @@ -55,6 +55,7 @@ internal sealed class PayloadJsonConverter : JsonConverter 1 => JsonSerializer.Deserialize(ref reader, options), 2 => JsonSerializer.Deserialize(ref reader, options), 3 => new HeartbeatPayload { SequenceNumber = reader.GetUInt64(), }, + 4 => JsonSerializer.Deserialize(ref reader, options), 6 => new HeartbeatAckPayload { SequenceNumber = reader.GetUInt64(), }, 8 => JsonSerializer.Deserialize(ref reader, options), _ => throw new JsonException($"Unknown operation code: {op}.") @@ -78,6 +79,7 @@ public override void Write(Utf8JsonWriter writer, IVoicePayload value, JsonSeria SelectProtocolPayload _ => 1, ReadyPayload _ => 2, HeartbeatPayload _ => 3, + SessionDescriptionPayload _ => 4, HeartbeatAckPayload _ => 6, HelloPayload _ => 8, _ => throw new JsonException("Unknown payload type.") diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SecretKeyJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SecretKeyJsonConverter.cs new file mode 100644 index 00000000..cdedb8a4 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/SecretKeyJsonConverter.cs @@ -0,0 +1,28 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +using System.Text.Json; +using System.Text.Json.Serialization; + +internal sealed class SecretKeyJsonConverter : JsonConverter> +{ + public override ReadOnlyMemory Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var byteData = JsonSerializer.Deserialize( + reader: ref reader, + jsonTypeInfo: PayloadJsonSerializerContext.Default.ImmutableArrayInt32); + + return byteData.Select(x => (byte)x).ToArray(); + } + + public override void Write(Utf8JsonWriter writer, ReadOnlyMemory value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + + foreach (var item in value.Span) + { + writer.WriteNumberValue(item); + } + + writer.WriteEndArray(); + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs index 2f148e01..9a21a2c5 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs @@ -1,5 +1,6 @@ namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; +using System.Collections.Immutable; using System.Text.Json.Serialization; [JsonSerializable(typeof(IVoicePayload))] @@ -7,6 +8,8 @@ [JsonSerializable(typeof(ReadyPayload))] [JsonSerializable(typeof(HelloPayload))] [JsonSerializable(typeof(SelectProtocolPayload))] +[JsonSerializable(typeof(SessionDescriptionPayload))] +[JsonSerializable(typeof(ImmutableArray))] internal sealed partial class PayloadJsonSerializerContext : JsonSerializerContext { } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SessionDescriptionPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SessionDescriptionPayload.cs new file mode 100644 index 00000000..d54cef8f --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SessionDescriptionPayload.cs @@ -0,0 +1,16 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; +using Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +internal sealed record class SessionDescriptionPayload : IVoicePayload +{ + [JsonRequired] + [JsonPropertyName("mode")] + public required string Mode { get; set; } + + [JsonRequired] + [JsonPropertyName("secret_key")] + [JsonConverter(typeof(SecretKeyJsonConverter))] + public required ReadOnlyMemory SecretKey { get; set; } +} From c2c2a58376b4969e218351ddb9b9e90befdb57d3 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 03:14:45 +0200 Subject: [PATCH 13/19] feat: Implement speaking payload --- .../Converters/PayloadJsonConverter.cs | 2 ++ .../Payloads/PayloadJsonSerializerContext.cs | 1 + .../Connections/Payloads/SpeakingFlags.cs | 10 ++++++++++ .../Connections/Payloads/SpeakingPayload.cs | 18 ++++++++++++++++++ 4 files changed, 31 insertions(+) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingFlags.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingPayload.cs diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs index 1b7d20c1..b9ac0680 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs @@ -56,6 +56,7 @@ internal sealed class PayloadJsonConverter : JsonConverter 2 => JsonSerializer.Deserialize(ref reader, options), 3 => new HeartbeatPayload { SequenceNumber = reader.GetUInt64(), }, 4 => JsonSerializer.Deserialize(ref reader, options), + 5 => JsonSerializer.Deserialize(ref reader, options), 6 => new HeartbeatAckPayload { SequenceNumber = reader.GetUInt64(), }, 8 => JsonSerializer.Deserialize(ref reader, options), _ => throw new JsonException($"Unknown operation code: {op}.") @@ -80,6 +81,7 @@ public override void Write(Utf8JsonWriter writer, IVoicePayload value, JsonSeria ReadyPayload _ => 2, HeartbeatPayload _ => 3, SessionDescriptionPayload _ => 4, + SpeakingPayload _ => 5, HeartbeatAckPayload _ => 6, HelloPayload _ => 8, _ => throw new JsonException("Unknown payload type.") diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs index 9a21a2c5..eddbc172 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs @@ -10,6 +10,7 @@ [JsonSerializable(typeof(SelectProtocolPayload))] [JsonSerializable(typeof(SessionDescriptionPayload))] [JsonSerializable(typeof(ImmutableArray))] +[JsonSerializable(typeof(SpeakingPayload))] internal sealed partial class PayloadJsonSerializerContext : JsonSerializerContext { } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingFlags.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingFlags.cs new file mode 100644 index 00000000..77847a01 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingFlags.cs @@ -0,0 +1,10 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +[Flags] +internal enum SpeakingFlags : byte +{ + None = 0, + Microphone = 1 << 0, + Soundshare = 1 << 1, + Priority = 1 << 2 +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingPayload.cs new file mode 100644 index 00000000..d3faa769 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingPayload.cs @@ -0,0 +1,18 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; + +internal sealed record class SpeakingPayload : IVoicePayload +{ + [JsonRequired] + [JsonPropertyName("speaking")] + public required SpeakingFlags Flags { get; set; } + + [JsonRequired] + [JsonPropertyName("delay")] + public required int Delay { get; set; } + + [JsonRequired] + [JsonPropertyName("ssrc")] + public required int Ssrc { get; set; } +} From d2a9f83feef33ed85001fb9106dd7b5614187dd6 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 03:17:35 +0200 Subject: [PATCH 14/19] feat: Implement resume/resumed payloads --- .../Converters/PayloadJsonConverter.cs | 4 ++++ .../Payloads/PayloadJsonSerializerContext.cs | 2 ++ .../Connections/Payloads/ResumePayload.cs | 20 +++++++++++++++++++ .../Connections/Payloads/ResumedPayload.cs | 5 +++++ 4 files changed, 31 insertions(+) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumePayload.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumedPayload.cs diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs index b9ac0680..59368ffe 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs @@ -58,7 +58,9 @@ internal sealed class PayloadJsonConverter : JsonConverter 4 => JsonSerializer.Deserialize(ref reader, options), 5 => JsonSerializer.Deserialize(ref reader, options), 6 => new HeartbeatAckPayload { SequenceNumber = reader.GetUInt64(), }, + 7 => JsonSerializer.Deserialize(ref reader, options), 8 => JsonSerializer.Deserialize(ref reader, options), + 9 => new ResumedPayload(), _ => throw new JsonException($"Unknown operation code: {op}.") }; @@ -83,7 +85,9 @@ public override void Write(Utf8JsonWriter writer, IVoicePayload value, JsonSeria SessionDescriptionPayload _ => 4, SpeakingPayload _ => 5, HeartbeatAckPayload _ => 6, + ResumePayload _ => 7, HelloPayload _ => 8, + ResumedPayload _ => 9, _ => throw new JsonException("Unknown payload type.") }; diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs index eddbc172..ed2073f8 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PayloadJsonSerializerContext.cs @@ -11,6 +11,8 @@ [JsonSerializable(typeof(SessionDescriptionPayload))] [JsonSerializable(typeof(ImmutableArray))] [JsonSerializable(typeof(SpeakingPayload))] +[JsonSerializable(typeof(ResumePayload))] +[JsonSerializable(typeof(ResumedPayload))] internal sealed partial class PayloadJsonSerializerContext : JsonSerializerContext { } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumePayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumePayload.cs new file mode 100644 index 00000000..80b5dfd7 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumePayload.cs @@ -0,0 +1,20 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Serialization; +using Lavalink4NET.Experiments.Receive.Connections.Payloads.Converters; + +internal sealed record class ResumePayload : IVoicePayload +{ + [JsonRequired] + [JsonConverter(typeof(SnowflakeJsonConverter))] + [JsonPropertyName("server_id")] + public required ulong GuildId { get; set; } + + [JsonRequired] + [JsonPropertyName("session_id")] + public required string SessionId { get; set; } + + [JsonRequired] + [JsonPropertyName("token")] + public required string Token { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumedPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumedPayload.cs new file mode 100644 index 00000000..49d262d5 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/ResumedPayload.cs @@ -0,0 +1,5 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +internal sealed record class ResumedPayload : IVoicePayload +{ +} From 649fcb9d412ef770a2b774e1e44aa1eaf5cc3bb8 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sat, 20 Apr 2024 23:32:51 +0200 Subject: [PATCH 15/19] feat: Implement VoiceProtocolHandler#WriteAsync --- .../Connections/IVoiceProtocolHandler.cs | 5 + .../Payloads/PooledBufferWriter.cs | 147 ++++++++++++++++++ .../Connections/VoiceProtocolHandler.cs | 18 +++ 3 files changed, 170 insertions(+) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PooledBufferWriter.cs diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs index aa70a131..6ed29312 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs @@ -8,4 +8,9 @@ internal interface IVoiceProtocolHandler ValueTask ReadAsync( WebSocket webSocket, CancellationToken cancellationToken = default); + + ValueTask WriteAsync( + WebSocket webSocket, + IVoicePayload payload, + CancellationToken cancellationToken = default); } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PooledBufferWriter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PooledBufferWriter.cs new file mode 100644 index 00000000..586501ef --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/PooledBufferWriter.cs @@ -0,0 +1,147 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Buffers; +using System.Diagnostics.CodeAnalysis; + +internal sealed class PooledBufferWriter : IBufferWriter, IDisposable +{ + private ArrayPool? _arrayPool; // null = disposed + private T[]? _buffer; + private int _bytesWritten; + + public PooledBufferWriter() + : this(ArrayPool.Shared) + { + } + + public PooledBufferWriter(ArrayPool arrayPool) + { + ArgumentNullException.ThrowIfNull(arrayPool); + + _arrayPool = arrayPool; + } + + public int Capacity => _buffer is null ? 0 : _buffer.Length; + + public int WrittenCount + { + get + { + EnsureNotDisposed(); + return _bytesWritten; + } + } + + public ReadOnlyMemory WrittenMemory + { + get + { + EnsureNotDisposed(); + return _buffer is null ? default : _buffer.AsMemory(0, _bytesWritten); + } + } + + public ArraySegment WrittenSegment + { + get + { + EnsureNotDisposed(); + + return _buffer is null + ? ArraySegment.Empty + : new ArraySegment(_buffer, 0, _bytesWritten); + } + } + + public ReadOnlySpan WrittenSpan => WrittenMemory.Span; + + /// + public void Advance(int count) + { + EnsureNotDisposed(); + + if (_buffer is null) + { + throw new InvalidOperationException("No buffer was allocated for this buffer writer."); + } + + // TODO: more checks + _bytesWritten += count; + } + + /// + public void Dispose() + { + if (_arrayPool is null) + { + return; + } + + var buffer = Interlocked.Exchange(ref _buffer, null); + + if (buffer is not null) + { + _arrayPool.Return(buffer); + } + + _arrayPool = null; + } + + /// + public Memory GetMemory(int sizeHint = 0) + { + EnsureNotDisposed(); + + if (sizeHint is 0) + { + sizeHint = 1; + } + + _buffer ??= _arrayPool.Rent(sizeHint); + + if (_buffer.Length - _bytesWritten < sizeHint) + { + var newBuffer = _arrayPool.Rent(sizeHint + _bytesWritten); + _buffer.AsSpan(0, _bytesWritten).CopyTo(newBuffer); + _arrayPool.Return(_buffer); + _buffer = newBuffer; + } + + return _buffer.AsMemory(_bytesWritten); + } + + public void EnsureCapacity(int capacity) + { + EnsureNotDisposed(); + + _buffer ??= _arrayPool.Rent(capacity); + + if (capacity > _buffer.Length) + { + var newBuffer = _arrayPool.Rent(capacity); + _buffer.AsSpan(0, _bytesWritten).CopyTo(newBuffer); + _arrayPool.Return(_buffer); + _buffer = newBuffer; + } + } + + /// + public Span GetSpan(int sizeHint = 0) => GetMemory(sizeHint).Span; + + public void Reset() + { + EnsureNotDisposed(); + + var buffer = Interlocked.Exchange(ref _buffer, null); + + if (buffer is not null) + { + _arrayPool.Return(buffer); + } + + _bytesWritten = 0; + } + + [MemberNotNull(nameof(_arrayPool))] + private void EnsureNotDisposed() => ObjectDisposedException.ThrowIf(_arrayPool is null, this); +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs index e0a8652d..6ec30e8f 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs @@ -38,4 +38,22 @@ public async ValueTask ReadAsync(WebSocket webSocket, Cancellatio ArrayPool.Shared.Return(pooledBuffer); } } + + public async ValueTask WriteAsync(WebSocket webSocket, IVoicePayload payload, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(webSocket); + ArgumentNullException.ThrowIfNull(payload); + + using var bufferWriter = new PooledBufferWriter(); + + using (var utf8JsonWriter = new Utf8JsonWriter(bufferWriter)) + { + JsonSerializer.Serialize(utf8JsonWriter, payload, PayloadJsonSerializerContext.Default.IVoicePayload); + } + + await webSocket + .SendAsync(bufferWriter.WrittenMemory, WebSocketMessageType.Text, true, cancellationToken) + .ConfigureAwait(false); + } } From 6f86e90342fbd8a1380722f92d453cc497b513e7 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sun, 28 Apr 2024 19:16:21 +0200 Subject: [PATCH 16/19] feat: Implement payload relaying --- .../Connections/CorrelationIdGenerator.cs | 28 ++++ .../Features/ConnectionIdFeature.cs | 8 + .../Features/ConnectionLabelFeature.cs | 3 + .../Connections/Features/GuildIdFeature.cs | 3 + .../Features/IConnectionLabelFeature.cs | 6 + .../Connections/Features/IGuildIdFeature.cs | 6 + .../Features/IVoiceGatewayVersionFeature.cs | 6 + .../Features/VoiceGatewayVersionFeature.cs | 3 + .../Connections/IVoiceConnectionHandler.cs | 4 +- .../Connections/IVoiceProtocolHandler.cs | 7 +- .../Connections/PayloadReadCloseResult.cs | 8 + .../Connections/PayloadReadResult.cs | 37 +++++ .../HeartbeatIntervalJsonConverter.cs | 4 +- .../Converters/PayloadJsonConverter.cs | 21 ++- .../Payloads/DynamicVoicePayload.cs | 10 ++ .../Connections/VoiceConnectionContext.cs | 19 +++ .../Connections/VoiceConnectionHandler.cs | 148 +++++++++++++++++- .../Connections/VoiceProtocolHandler.cs | 84 ++++++++-- .../Lavalink4NET.Experiments.Receive.csproj | 2 +- .../Program.cs | 2 - .../Properties/launchSettings.json | 12 ++ .../Server/LavalinkVoiceServer.cs | 21 ++- src/Lavalink4NET/Players/PlayerManager.cs | 4 + 23 files changed, 415 insertions(+), 31 deletions(-) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/CorrelationIdGenerator.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionIdFeature.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionLabelFeature.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Features/GuildIdFeature.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IConnectionLabelFeature.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IGuildIdFeature.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IVoiceGatewayVersionFeature.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Features/VoiceGatewayVersionFeature.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadCloseResult.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadResult.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/DynamicVoicePayload.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionContext.cs delete mode 100644 experiments/Lavalink4NET.Experiments.Receive/Program.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Properties/launchSettings.json diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/CorrelationIdGenerator.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/CorrelationIdGenerator.cs new file mode 100644 index 00000000..4493cc95 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/CorrelationIdGenerator.cs @@ -0,0 +1,28 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +// Based on https://source.dot.net/#Microsoft.AspNetCore.Server.Kestrel.Transport.Sockets/src/Servers/Kestrel/shared/CorrelationIdGenerator.cs,f22660215e7e9131 + +internal static class CorrelationIdGenerator +{ + private static ReadOnlySpan Encode32Chars => "0123456789ABCDEFGHIJKLMNOPQRSTUV"; + private static long _lastId = DateTime.UtcNow.Ticks; + + public static string GetNextId() => GenerateId(Interlocked.Increment(ref _lastId)); + + private static string GenerateId(long id) => string.Create(13, id, (buffer, value) => + { + buffer[12] = Encode32Chars[(int)(value & 31)]; + buffer[11] = Encode32Chars[(int)((value >> 5) & 31)]; + buffer[10] = Encode32Chars[(int)((value >> 10) & 31)]; + buffer[9] = Encode32Chars[(int)((value >> 15) & 31)]; + buffer[8] = Encode32Chars[(int)((value >> 20) & 31)]; + buffer[7] = Encode32Chars[(int)((value >> 25) & 31)]; + buffer[6] = Encode32Chars[(int)((value >> 30) & 31)]; + buffer[5] = Encode32Chars[(int)((value >> 35) & 31)]; + buffer[4] = Encode32Chars[(int)((value >> 40) & 31)]; + buffer[3] = Encode32Chars[(int)((value >> 45) & 31)]; + buffer[2] = Encode32Chars[(int)((value >> 50) & 31)]; + buffer[1] = Encode32Chars[(int)((value >> 55) & 31)]; + buffer[0] = Encode32Chars[(int)((value >> 60) & 31)]; + }); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionIdFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionIdFeature.cs new file mode 100644 index 00000000..e5f05517 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionIdFeature.cs @@ -0,0 +1,8 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +using Microsoft.AspNetCore.Connections.Features; + +internal sealed record class ConnectionIdFeature : IConnectionIdFeature +{ + public required string ConnectionId { get; set; } +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionLabelFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionLabelFeature.cs new file mode 100644 index 00000000..31e0fa44 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/ConnectionLabelFeature.cs @@ -0,0 +1,3 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +internal sealed record class ConnectionLabelFeature(string Label) : IConnectionLabelFeature; \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/GuildIdFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/GuildIdFeature.cs new file mode 100644 index 00000000..1d9e54c5 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/GuildIdFeature.cs @@ -0,0 +1,3 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +internal sealed record class GuildIdFeature(ulong GuildId) : IGuildIdFeature; \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IConnectionLabelFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IConnectionLabelFeature.cs new file mode 100644 index 00000000..971eb057 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IConnectionLabelFeature.cs @@ -0,0 +1,6 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +public interface IConnectionLabelFeature +{ + string Label { get; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IGuildIdFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IGuildIdFeature.cs new file mode 100644 index 00000000..455f6c79 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IGuildIdFeature.cs @@ -0,0 +1,6 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +public interface IGuildIdFeature +{ + ulong GuildId { get; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IVoiceGatewayVersionFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IVoiceGatewayVersionFeature.cs new file mode 100644 index 00000000..7d7897ed --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/IVoiceGatewayVersionFeature.cs @@ -0,0 +1,6 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +public interface IVoiceGatewayVersionFeature +{ + int Version { get; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/VoiceGatewayVersionFeature.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/VoiceGatewayVersionFeature.cs new file mode 100644 index 00000000..123dcda3 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Features/VoiceGatewayVersionFeature.cs @@ -0,0 +1,3 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Features; + +internal sealed record class VoiceGatewayVersionFeature(int Version) : IVoiceGatewayVersionFeature; \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs index d53e4e32..cfb253e8 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs @@ -1,10 +1,8 @@ namespace Lavalink4NET.Experiments.Receive.Connections; -using System.Net.WebSockets; - public interface IVoiceConnectionHandler { ValueTask ProcessAsync( - WebSocket webSocket, + VoiceConnectionContext connectionContext, CancellationToken cancellationToken = default); } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs index 6ed29312..544023ae 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceProtocolHandler.cs @@ -1,16 +1,15 @@ namespace Lavalink4NET.Experiments.Receive.Connections; -using System.Net.WebSockets; using Lavalink4NET.Experiments.Receive.Connections.Payloads; internal interface IVoiceProtocolHandler { - ValueTask ReadAsync( - WebSocket webSocket, + ValueTask ReadAsync( + VoiceConnectionContext connectionContext, CancellationToken cancellationToken = default); ValueTask WriteAsync( - WebSocket webSocket, + VoiceConnectionContext connectionContext, IVoicePayload payload, CancellationToken cancellationToken = default); } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadCloseResult.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadCloseResult.cs new file mode 100644 index 00000000..97252300 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadCloseResult.cs @@ -0,0 +1,8 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Net.WebSockets; + +internal sealed record class PayloadReadCloseResult( + WebSocketCloseStatus CloseStatus, + string? CloseStatusDescription, + bool ByRemote = false); \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadResult.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadResult.cs new file mode 100644 index 00000000..9aa0df40 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/PayloadReadResult.cs @@ -0,0 +1,37 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Diagnostics.CodeAnalysis; +using System.Net.WebSockets; +using Lavalink4NET.Experiments.Receive.Connections.Payloads; + +internal readonly record struct PayloadReadResult +{ + private readonly object? _value; + + public PayloadReadResult(IVoicePayload payload) + { + ArgumentNullException.ThrowIfNull(payload); + + _value = payload; + } + + public PayloadReadResult(WebSocketCloseStatus closeStatus, string? closeStatusDescription, bool byRemote = false) + { + _value = new PayloadReadCloseResult(closeStatus, closeStatusDescription, byRemote); + } + + public IVoicePayload? Payload => _value as IVoicePayload; + + public WebSocketCloseStatus CloseStatus => _value is PayloadReadCloseResult closeResult + ? closeResult.CloseStatus + : WebSocketCloseStatus.Empty; + + public string? CloseStatusDescription => _value is PayloadReadCloseResult closeResult + ? closeResult.CloseStatusDescription + : null; + + public bool ByRemote => _value is PayloadReadCloseResult closeResult && closeResult.ByRemote; + + [MemberNotNullWhen(true, nameof(Payload))] + public bool IsSuccess => _value is IVoicePayload; +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/HeartbeatIntervalJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/HeartbeatIntervalJsonConverter.cs index 3d94e2f5..bc63647a 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/HeartbeatIntervalJsonConverter.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/HeartbeatIntervalJsonConverter.cs @@ -7,11 +7,11 @@ internal sealed class HeartbeatIntervalJsonConverter : JsonConverter { public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - return TimeSpan.FromMilliseconds(reader.GetUInt64()); + return TimeSpan.FromMilliseconds(reader.GetDouble()); } public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) { - writer.WriteNumberValue((ulong)value.TotalMilliseconds); + writer.WriteNumberValue(value.TotalMilliseconds); } } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs index 59368ffe..fd32e5dd 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/Converters/PayloadJsonConverter.cs @@ -2,6 +2,7 @@ using System; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; internal sealed class PayloadJsonConverter : JsonConverter @@ -61,7 +62,7 @@ internal sealed class PayloadJsonConverter : JsonConverter 7 => JsonSerializer.Deserialize(ref reader, options), 8 => JsonSerializer.Deserialize(ref reader, options), 9 => new ResumedPayload(), - _ => throw new JsonException($"Unknown operation code: {op}.") + _ => new DynamicVoicePayload { OperationCode = op, Data = (JsonObject)JsonNode.Parse(ref reader)!, }, }; if (!reader.Read() || reader.TokenType != JsonTokenType.EndObject) @@ -76,6 +77,24 @@ public override void Write(Utf8JsonWriter writer, IVoicePayload value, JsonSeria { writer.WriteStartObject(); + if (value is DynamicVoicePayload dynamicPayload) + { + writer.WriteNumber("op", dynamicPayload.OperationCode); + writer.WritePropertyName("d"); + + if (dynamicPayload.Data is null) + { + writer.WriteNullValue(); + } + else + { + dynamicPayload.Data.WriteTo(writer); + } + + writer.WriteEndObject(); + return; + } + var opCode = value switch { IdentifyPayload _ => 0, diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/DynamicVoicePayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/DynamicVoicePayload.cs new file mode 100644 index 00000000..5f084312 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/DynamicVoicePayload.cs @@ -0,0 +1,10 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Payloads; + +using System.Text.Json.Nodes; + +public sealed record class DynamicVoicePayload : IVoicePayload +{ + public int OperationCode { get; set; } + + public JsonObject? Data { get; set; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionContext.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionContext.cs new file mode 100644 index 00000000..c9507e0d --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionContext.cs @@ -0,0 +1,19 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Net.WebSockets; +using Microsoft.AspNetCore.Http.Features; + +public sealed class VoiceConnectionContext +{ + public VoiceConnectionContext(WebSocket webSocket) + { + ArgumentNullException.ThrowIfNull(webSocket); + + WebSocket = webSocket; + Features = new FeatureCollection(); + } + + public WebSocket WebSocket { get; } + + public IFeatureCollection Features { get; } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs index 336c9d9c..c853b945 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs @@ -1,47 +1,179 @@ namespace Lavalink4NET.Experiments.Receive.Connections; +using System.Globalization; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; +using System.Web; +using Lavalink4NET.Clients; +using Lavalink4NET.Experiments.Receive.Connections.Features; using Lavalink4NET.Experiments.Receive.Connections.Payloads; using Lavalink4NET.Experiments.Receive.Sessions; +using Microsoft.AspNetCore.Connections.Features; internal sealed class VoiceConnectionHandler : IVoiceConnectionHandler { private readonly IVoiceServerSessionManager _sessionManager; private readonly IVoiceProtocolHandler _protocolHandler; + private readonly IHttpMessageHandlerFactory _httpMessageHandlerFactory; + private readonly ILogger _logger; public VoiceConnectionHandler( IVoiceServerSessionManager sessionManager, - IVoiceProtocolHandler protocolHandler) + IVoiceProtocolHandler protocolHandler, + IHttpMessageHandlerFactory httpMessageHandlerFactory, + ILogger logger) { ArgumentNullException.ThrowIfNull(sessionManager); ArgumentNullException.ThrowIfNull(protocolHandler); + ArgumentNullException.ThrowIfNull(httpMessageHandlerFactory); + ArgumentNullException.ThrowIfNull(logger); _sessionManager = sessionManager; _protocolHandler = protocolHandler; + _httpMessageHandlerFactory = httpMessageHandlerFactory; + _logger = logger; } - public async ValueTask ProcessAsync(WebSocket webSocket, CancellationToken cancellationToken = default) + public async ValueTask ProcessAsync( + VoiceConnectionContext connectionContext, + CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - ArgumentNullException.ThrowIfNull(webSocket); + ArgumentNullException.ThrowIfNull(connectionContext); - var payload = await _protocolHandler - .ReadAsync(webSocket, cancellationToken) + var payloadResult = await _protocolHandler + .ReadAsync(connectionContext, cancellationToken) .ConfigureAwait(false); - if (payload is not IdentifyPayload identifyPayload) + if (!payloadResult.IsSuccess) + { + throw new WebSocketException($"Failed to read initial payload: {payloadResult.CloseStatus} {payloadResult.CloseStatusDescription}"); + } + + if (payloadResult.Payload is not IdentifyPayload identifyPayload) { throw new WebSocketException("Expected identify payload."); } - if (!Guid.TryParseExact(identifyPayload.Token, "N", out var token) || + if (!Guid.TryParseExact(identifyPayload.Token, "N", result: out var token) || !_sessionManager.TryResolve(token, out var guildId, out var voiceServer)) { throw new WebSocketException("Invalid session id."); } - ; + string sourceConnectionId; + if (connectionContext.Features.Get() is { } connectionIdFeature) + { + sourceConnectionId = connectionIdFeature.ConnectionId; + } + else + { + sourceConnectionId = CorrelationIdGenerator.GetNextId(); + connectionContext.Features.Set(new ConnectionIdFeature { ConnectionId = sourceConnectionId }); + } + + var remoteConnectionId = CorrelationIdGenerator.GetNextId(); + var sourceLabel = $"Local/{sourceConnectionId}@{guildId}"; + var remoteLabel = $"Remote/{sourceConnectionId}@{guildId}"; + + connectionContext.Features.Set(new GuildIdFeature(guildId)); + connectionContext.Features.Set(new ConnectionLabelFeature(sourceLabel)); + + using var gatewaySocket = new ClientWebSocket(); + using var httpMessageHandler = _httpMessageHandlerFactory.CreateHandler(); + using var httpMessageInvoker = new HttpMessageInvoker(httpMessageHandler); + var version = connectionContext.Features.Get()?.Version; + + var uri = BuildUri(voiceServer, version); + + await gatewaySocket + .ConnectAsync(uri, httpMessageInvoker, cancellationToken) + .ConfigureAwait(false); + + var remoteConnectionContext = new VoiceConnectionContext(gatewaySocket); + + remoteConnectionContext.Features.Set(new ConnectionIdFeature { ConnectionId = remoteConnectionId, }); + remoteConnectionContext.Features.Set(new ConnectionLabelFeature(remoteLabel)); + remoteConnectionContext.Features.Set(new VoiceGatewayVersionFeature(version ?? 4)); + remoteConnectionContext.Features.Set(new GuildIdFeature(guildId)); + + var remoteIdentifyPayload = new IdentifyPayload + { + GuildId = guildId, + Token = voiceServer.Token, + SessionId = identifyPayload.SessionId, + UserId = identifyPayload.UserId, + }; + + await _protocolHandler + .WriteAsync(remoteConnectionContext, remoteIdentifyPayload, cancellationToken) + .ConfigureAwait(false); + + var task1 = ProxyAsync(connectionContext, remoteConnectionContext, cancellationToken).AsTask(); + var task2 = ProxyAsync(remoteConnectionContext, connectionContext, cancellationToken).AsTask(); + + await Task + .WhenAny(task1, task2) + .ConfigureAwait(false); + } + + private async ValueTask ProxyAsync( + VoiceConnectionContext sourceConnectionContext, + VoiceConnectionContext destinationConnectionContext, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(sourceConnectionContext); + ArgumentNullException.ThrowIfNull(destinationConnectionContext); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await _protocolHandler + .ReadAsync(sourceConnectionContext, cancellationToken) + .ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.LogWarning("Failed to read payload: {CloseStatus} {CloseStatusDescription}", result.CloseStatus, result.CloseStatusDescription); + break; + } + + await _protocolHandler + .WriteAsync(destinationConnectionContext, result.Payload, cancellationToken) + .ConfigureAwait(false); + } + } + + private static Uri BuildUri(VoiceServer voiceServer, int? version = null) + { + var host = voiceServer.Endpoint; + var endPointSeparatorIndex = host.LastIndexOf(':'); + var port = default(int?); // WSS default port + + if (endPointSeparatorIndex is not -1) + { + host = host[..endPointSeparatorIndex]; + port = int.Parse(voiceServer.Endpoint[(endPointSeparatorIndex + 1)..]); + } + + var uriBuilder = new UriBuilder + { + Scheme = Uri.UriSchemeWss, + Host = host, + Port = port ?? 443, + Path = "/", + }; + + if (version.HasValue) + { + var queryParameters = HttpUtility.ParseQueryString(string.Empty); + queryParameters["v"] = version.Value.ToString(CultureInfo.InvariantCulture); + uriBuilder.Query = queryParameters.ToString(); + } + + return uriBuilder.Uri; } } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs index 6ec30e8f..8208395c 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs @@ -2,17 +2,35 @@ using System.Buffers; using System.Net.WebSockets; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Lavalink4NET.Experiments.Receive.Connections.Features; using Lavalink4NET.Experiments.Receive.Connections.Payloads; +using Microsoft.AspNetCore.Connections.Features; +using Microsoft.AspNetCore.Http.Features; internal sealed class VoiceProtocolHandler : IVoiceProtocolHandler { - public async ValueTask ReadAsync(WebSocket webSocket, CancellationToken cancellationToken = default) + private readonly ILogger _logger; + + public VoiceProtocolHandler(ILogger logger) + { + ArgumentNullException.ThrowIfNull(logger); + + _logger = logger; + } + + public async ValueTask ReadAsync( + VoiceConnectionContext connectionContext, + CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - ArgumentNullException.ThrowIfNull(webSocket); + ArgumentNullException.ThrowIfNull(connectionContext); + + var label = connectionContext.Features.Get()?.Label + ?? connectionContext.Features.GetRequiredFeature().ConnectionId; var pooledBuffer = ArrayPool.Shared.Rent(64 * 1024); @@ -20,18 +38,55 @@ public async ValueTask ReadAsync(WebSocket webSocket, Cancellatio { var buffer = new Memory(pooledBuffer); - var result = await webSocket + var result = await connectionContext.WebSocket .ReceiveAsync(buffer, cancellationToken) .ConfigureAwait(false); - if (result.MessageType == WebSocketMessageType.Close) + if (result.MessageType is WebSocketMessageType.Close) + { + var closeStatus = connectionContext.WebSocket.CloseStatus ?? WebSocketCloseStatus.NormalClosure; + + _logger.LogInformation( + "[{Label}] Lost connection to voice gateway: {CloseStatus} {CloseStatusDescription}.", + label, closeStatus, connectionContext.WebSocket.CloseStatusDescription); + + return new PayloadReadResult(closeStatus, connectionContext.WebSocket.CloseStatusDescription, byRemote: true); + } + + if (result.MessageType is not WebSocketMessageType.Text) + { + _logger.LogWarning("[{Label}] Received a non-text message over the voice gateway connection.", label); + + await connectionContext.WebSocket + .CloseAsync(WebSocketCloseStatus.InvalidMessageType, "Expected text message.", cancellationToken) + .ConfigureAwait(false); + + return new PayloadReadResult(WebSocketCloseStatus.InvalidMessageType, "Expected text message.", byRemote: false); + } + + if (!result.EndOfMessage) { - throw new WebSocketException("WebSocket connection is closing."); + _logger.LogWarning(label, "[{Label}] Received a partial payload from voice gateway."); + + await connectionContext.WebSocket + .CloseAsync(WebSocketCloseStatus.MessageTooBig, "Payload is too large.", cancellationToken) + .ConfigureAwait(false); + + return new PayloadReadResult(WebSocketCloseStatus.MessageTooBig, "Payload is too large.", byRemote: false); } var payloadBuffer = buffer[..result.Count]; - return JsonSerializer.Deserialize(payloadBuffer.Span, PayloadJsonSerializerContext.Default.IVoicePayload)!; + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("[{Label}] Received payload: {Payload}", label, Encoding.UTF8.GetString(payloadBuffer.Span)); + } + + var payload = JsonSerializer.Deserialize( + utf8Json: payloadBuffer.Span, + jsonTypeInfo: PayloadJsonSerializerContext.Default.IVoicePayload)!; + + return new PayloadReadResult(payload); } finally { @@ -39,12 +94,18 @@ public async ValueTask ReadAsync(WebSocket webSocket, Cancellatio } } - public async ValueTask WriteAsync(WebSocket webSocket, IVoicePayload payload, CancellationToken cancellationToken = default) + public async ValueTask WriteAsync( + VoiceConnectionContext connectionContext, + IVoicePayload payload, + CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - ArgumentNullException.ThrowIfNull(webSocket); + ArgumentNullException.ThrowIfNull(connectionContext); ArgumentNullException.ThrowIfNull(payload); + var label = connectionContext.Features.Get()?.Label + ?? connectionContext.Features.GetRequiredFeature().ConnectionId; + using var bufferWriter = new PooledBufferWriter(); using (var utf8JsonWriter = new Utf8JsonWriter(bufferWriter)) @@ -52,7 +113,12 @@ public async ValueTask WriteAsync(WebSocket webSocket, IVoicePayload payload, Ca JsonSerializer.Serialize(utf8JsonWriter, payload, PayloadJsonSerializerContext.Default.IVoicePayload); } - await webSocket + if (_logger.IsEnabled(LogLevel.Trace)) + { + _logger.LogTrace("[{Label}] Sending payload: {Payload}", label, Encoding.UTF8.GetString(bufferWriter.WrittenSpan)); + } + + await connectionContext.WebSocket .SendAsync(bufferWriter.WrittenMemory, WebSocketMessageType.Text, true, cancellationToken) .ConfigureAwait(false); } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Lavalink4NET.Experiments.Receive.csproj b/experiments/Lavalink4NET.Experiments.Receive/Lavalink4NET.Experiments.Receive.csproj index adc3ba7b..1010bcb9 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Lavalink4NET.Experiments.Receive.csproj +++ b/experiments/Lavalink4NET.Experiments.Receive/Lavalink4NET.Experiments.Receive.csproj @@ -1,7 +1,7 @@  - Exe + Library net8.0 enable enable diff --git a/experiments/Lavalink4NET.Experiments.Receive/Program.cs b/experiments/Lavalink4NET.Experiments.Receive/Program.cs deleted file mode 100644 index 3751555c..00000000 --- a/experiments/Lavalink4NET.Experiments.Receive/Program.cs +++ /dev/null @@ -1,2 +0,0 @@ -// See https://aka.ms/new-console-template for more information -Console.WriteLine("Hello, World!"); diff --git a/experiments/Lavalink4NET.Experiments.Receive/Properties/launchSettings.json b/experiments/Lavalink4NET.Experiments.Receive/Properties/launchSettings.json new file mode 100644 index 00000000..58992fac --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Lavalink4NET.Experiments.Receive": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:56958;http://localhost:56959" + } + } +} \ No newline at end of file diff --git a/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs index 7bc5b3a9..336292db 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs @@ -2,7 +2,10 @@ using System; using System.Diagnostics.Metrics; +using System.Globalization; using Lavalink4NET.Experiments.Receive.Connections; +using Lavalink4NET.Experiments.Receive.Connections.Features; +using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.WebSockets; @@ -114,14 +117,30 @@ await httpContext.Response return; } + if (!int.TryParse(versionValue.ToString(), CultureInfo.InvariantCulture, out var version) || version is not 3 and not 4) + { + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + + await httpContext.Response + .WriteAsync("Invalid version parameter.") + .ConfigureAwait(false); + + return; + } + var webSocketAcceptContext = new WebSocketAcceptContext { }; var webSocket = await httpContext.WebSockets .AcceptWebSocketAsync(webSocketAcceptContext) .ConfigureAwait(false); + var connectionContext = new VoiceConnectionContext(webSocket); + + connectionContext.Features.Set(httpContext.Features.GetRequiredFeature()); + connectionContext.Features.Set(new VoiceGatewayVersionFeature(version)); + await _voiceConnectionHandler - .ProcessAsync(webSocket, cancellationToken) + .ProcessAsync(connectionContext, cancellationToken) .ConfigureAwait(false); } } diff --git a/src/Lavalink4NET/Players/PlayerManager.cs b/src/Lavalink4NET/Players/PlayerManager.cs index ce9724d2..9fa376e7 100644 --- a/src/Lavalink4NET/Players/PlayerManager.cs +++ b/src/Lavalink4NET/Players/PlayerManager.cs @@ -136,6 +136,10 @@ LavalinkPlayerHandle Create(ulong guildId) logger: _loggerFactory.CreateLogger()); } + await _playerContext.DiscordClient + .WaitForReadyAsync(cancellationToken) + .ConfigureAwait(false); + var handle = _handles.GetOrAdd(guildId, Create); if (handle.Player?.VoiceChannelId != voiceChannelId) From 39433c0fc5cb0aad52f77d03422b1d43a17753f2 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sun, 28 Apr 2024 19:18:02 +0200 Subject: [PATCH 17/19] fix: Add missing audio_codec field in session description payload --- .../Connections/Payloads/SessionDescriptionPayload.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SessionDescriptionPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SessionDescriptionPayload.cs index d54cef8f..1c6422e1 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SessionDescriptionPayload.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SessionDescriptionPayload.cs @@ -9,6 +9,10 @@ internal sealed record class SessionDescriptionPayload : IVoicePayload [JsonPropertyName("mode")] public required string Mode { get; set; } + [JsonRequired] + [JsonPropertyName("audio_codec")] + public required string AudioCodec { get; set; } + [JsonRequired] [JsonPropertyName("secret_key")] [JsonConverter(typeof(SecretKeyJsonConverter))] From 73f5b6946eac30e7aba9528e3e26f5e96dd051bb Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sun, 28 Apr 2024 21:59:19 +0200 Subject: [PATCH 18/19] feat: Implement audio relay and IP discovery --- .../Discovery/IIpDiscoveryService.cs | 9 + .../Discovery/IpDiscoveryService.cs | 107 +++++++++++ .../Connections/IVoiceConnectionHandle.cs | 15 ++ .../Connections/IVoiceConnectionHandler.cs | 3 +- .../Connections/Payloads/SpeakingPayload.cs | 3 +- .../Connections/VoiceConnectionHandle.cs | 171 ++++++++++++++++++ .../Connections/VoiceConnectionHandler.cs | 54 +++++- .../Connections/VoiceProtocolHandler.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 2 + .../Server/LavalinkVoiceServer.cs | 7 +- 10 files changed, 364 insertions(+), 9 deletions(-) create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IIpDiscoveryService.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IpDiscoveryService.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandle.cs create mode 100644 experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandle.cs diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IIpDiscoveryService.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IIpDiscoveryService.cs new file mode 100644 index 00000000..9c076d86 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IIpDiscoveryService.cs @@ -0,0 +1,9 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Discovery; + +using System.Net; +using System.Net.Sockets; + +internal interface IIpDiscoveryService +{ + ValueTask DiscoverExternalAddressAsync(Socket socket, uint ssrc, CancellationToken cancellationToken = default); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IpDiscoveryService.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IpDiscoveryService.cs new file mode 100644 index 00000000..b7d63de3 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Discovery/IpDiscoveryService.cs @@ -0,0 +1,107 @@ +namespace Lavalink4NET.Experiments.Receive.Connections.Discovery; + +using System.Buffers; +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Text; + +internal sealed class IpDiscoveryService : IIpDiscoveryService +{ + public async ValueTask DiscoverExternalAddressAsync(Socket socket, uint ssrc, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(socket); + + using var periodicTimer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + + try + { + do + { + // discover external address + var address = await DiscoverExternalAddressSingleAsync( + socket: socket, + ssrc: ssrc, + cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (address is not null) + { + // got response! + return address; + } + } + while (await periodicTimer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)); + } + catch (OperationCanceledException) + { + } + + // no attempts left, give up or cancellation requested + return null; + } + + private async ValueTask DiscoverExternalAddressSingleAsync(Socket socket, uint ssrc, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + ArgumentNullException.ThrowIfNull(socket); + + // rent a buffer from the shared buffer array pool with a minimum size of 74 bytes (can + // hold the request). + var pooledBuffer = ArrayPool.Shared.Rent(74); + var buffer = pooledBuffer.AsMemory(0, 74); + + try + { + // encode payload data + BinaryPrimitives.WriteUInt16BigEndian(buffer.Span[0..], 0x01); // Request Payload Type + BinaryPrimitives.WriteUInt16BigEndian(buffer.Span[2..], 70); // encoded payload size (always 70) + BinaryPrimitives.WriteUInt32BigEndian(buffer.Span[4..], ssrc); // encode the client's SSRC (big-endian) + + // send payload + await socket + .SendAsync(buffer, SocketFlags.None, cancellationToken) + .ConfigureAwait(false); + + var startTime = DateTimeOffset.UtcNow; + + while (!cancellationToken.IsCancellationRequested) + { + var receiveResult = await socket + .ReceiveFromAsync(buffer, SocketFlags.None, new IPEndPoint(IPAddress.Any, 0), cancellationToken: cancellationToken) + .ConfigureAwait(false); + + if (receiveResult.ReceivedBytes is not 74) // Total Length + { + continue; + } + + var payloadType = BinaryPrimitives.ReadUInt16BigEndian(buffer.Span[0..]); + var encodedSize = BinaryPrimitives.ReadUInt16BigEndian(buffer.Span[2..]); + var ssrcValue = BinaryPrimitives.ReadUInt32BigEndian(buffer.Span[4..]); + + // validate header + if (payloadType is 0x02 && encodedSize is 70 && ssrcValue == ssrc) + { + var addressSpan = buffer[8..64]; + var addressTerminatorOffset = addressSpan.Span.IndexOf((byte)0); + var addressLength = addressTerminatorOffset is -1 ? 64 : addressTerminatorOffset; + var address = Encoding.ASCII.GetString(addressSpan.Span[..addressLength]); + var port = BinaryPrimitives.ReadUInt16BigEndian(buffer.Span[72..]); + + return new IPEndPoint(IPAddress.Parse(address), port); + } + } + } + finally + { + // return buffer to pool + ArrayPool.Shared.Return(pooledBuffer); + } + + // timeout exceeded + return null; + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandle.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandle.cs new file mode 100644 index 00000000..228bc8a8 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandle.cs @@ -0,0 +1,15 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Lavalink4NET.Experiments.Receive.Connections.Payloads; + +internal interface IVoiceConnectionHandle +{ + ValueTask SelectProtocolAsync(SelectProtocolPayload selectProtocolPayload, CancellationToken cancellationToken = default); + + ValueTask SetSessionDescriptionAsync(SessionDescriptionPayload sessionDescriptionPayload, CancellationToken cancellationToken = default); + + ValueTask SetReadyAsync(ReadyPayload readyPayload, CancellationToken cancellationToken = default); +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs index cfb253e8..475e95e5 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/IVoiceConnectionHandler.cs @@ -1,8 +1,9 @@ namespace Lavalink4NET.Experiments.Receive.Connections; -public interface IVoiceConnectionHandler +internal interface IVoiceConnectionHandler { ValueTask ProcessAsync( VoiceConnectionContext connectionContext, + IVoiceConnectionHandle connectionHandle, CancellationToken cancellationToken = default); } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingPayload.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingPayload.cs index d3faa769..8bce8a2d 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingPayload.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/Payloads/SpeakingPayload.cs @@ -8,9 +8,8 @@ internal sealed record class SpeakingPayload : IVoicePayload [JsonPropertyName("speaking")] public required SpeakingFlags Flags { get; set; } - [JsonRequired] [JsonPropertyName("delay")] - public required int Delay { get; set; } + public int? Delay { get; set; } [JsonRequired] [JsonPropertyName("ssrc")] diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandle.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandle.cs new file mode 100644 index 00000000..78fa4429 --- /dev/null +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandle.cs @@ -0,0 +1,171 @@ +namespace Lavalink4NET.Experiments.Receive.Connections; + +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using System.Text; +using Lavalink4NET.Experiments.Receive.Connections.Discovery; +using Lavalink4NET.Experiments.Receive.Connections.Payloads; + +internal sealed class VoiceConnectionHandle : IVoiceConnectionHandle +{ + private readonly IIpDiscoveryService _ipDiscoveryService; + private SelectProtocolPayload? _selectProtocolPayload; + private ReadyPayload? _readyPayload; + private SessionDescriptionPayload? _sessionDescriptionPayload; + private Socket? _localSocket; + private Socket? _remoteSocket; + + public VoiceConnectionHandle(IIpDiscoveryService ipDiscoveryService) + { + ArgumentNullException.ThrowIfNull(ipDiscoveryService); + + _ipDiscoveryService = ipDiscoveryService; + } + + public async ValueTask SelectProtocolAsync(SelectProtocolPayload selectProtocolPayload, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(selectProtocolPayload); + + _selectProtocolPayload = selectProtocolPayload; + + using var discoveryCancellationTokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + + var externalRemoteAddress = await _ipDiscoveryService + .DiscoverExternalAddressAsync(_remoteSocket!, _readyPayload!.Ssrc, discoveryCancellationTokenSource.Token) + .ConfigureAwait(false); + + await CompleteAsync(cancellationToken).ConfigureAwait(false); + + return externalRemoteAddress; + } + + public async ValueTask SetReadyAsync(ReadyPayload readyPayload, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(readyPayload); + + if (_readyPayload is not null) + { + throw new InvalidOperationException("Ready payload already received."); + } + + _readyPayload = readyPayload; + + _localSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _localSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + + _remoteSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _remoteSocket.Bind(new IPEndPoint(IPAddress.Any, _readyPayload.Port)); + _remoteSocket.Connect(new IPEndPoint(IPAddress.Parse(_readyPayload.Ip), _readyPayload.Port)); + + _ = ProxyAsync(_localSocket!, _remoteSocket!, cancellationToken).AsTask(); + + await CompleteAsync(cancellationToken).ConfigureAwait(false); + + return (IPEndPoint)_localSocket.LocalEndPoint!; + } + + public async ValueTask SetSessionDescriptionAsync(SessionDescriptionPayload sessionDescriptionPayload, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(sessionDescriptionPayload); + + _sessionDescriptionPayload = sessionDescriptionPayload; + + await CompleteAsync(cancellationToken).ConfigureAwait(false); + } + + private ValueTask CompleteAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_selectProtocolPayload is null || _readyPayload is null || _sessionDescriptionPayload is null) + { + return default; + } + + _localSocket!.Connect(new IPEndPoint(IPAddress.Parse(_selectProtocolPayload.Data.Address), _selectProtocolPayload.Data.Port)); + _ = ProxyAsync(_remoteSocket!, _localSocket!, cancellationToken).AsTask(); + + return default; + } + + private async ValueTask HandleIpDiscoveryAsync(Socket sourceSocket, EndPoint endPoint, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentNullException.ThrowIfNull(sourceSocket); + + using var bufferWriter = new PooledBufferWriter(); + + var header = bufferWriter.GetMemory(8); + + BinaryPrimitives.WriteUInt16BigEndian(header.Span[0..2], 0x02); // Mark as response + BinaryPrimitives.WriteUInt16BigEndian(header.Span[2..4], 70); // Encode (constant) length + BinaryPrimitives.WriteUInt32BigEndian(header.Span[4..8], _readyPayload!.Ssrc); // Encode SSRC + + bufferWriter.Advance(8); + + var localEndPoint = (IPEndPoint)sourceSocket.LocalEndPoint!; + + // Encode IP + var ipContent = bufferWriter.GetMemory(64); + var encodedByteCount = Encoding.UTF8.GetBytes(localEndPoint.Address.ToString(), ipContent.Span); + ipContent.Span[encodedByteCount] = 0; + bufferWriter.Advance(64); + + // Encode port + var portContent = bufferWriter.GetMemory(2); + BinaryPrimitives.WriteUInt16BigEndian(portContent.Span, (ushort)localEndPoint.Port); + bufferWriter.Advance(2); + + await sourceSocket + .SendToAsync(bufferWriter.WrittenMemory, SocketFlags.None, endPoint, cancellationToken) + .ConfigureAwait(false); + } + + private async ValueTask ProxyAsync(Socket sourceSocket, Socket destinationSocket, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var buffer = ArrayPool.Shared.Rent(64 * 1024); + + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var result = await sourceSocket + .ReceiveMessageFromAsync(buffer, SocketFlags.None, sourceSocket.LocalEndPoint!, cancellationToken) + .ConfigureAwait(false); + + if (result.ReceivedBytes is 0) + { + break; + } + + var data = new ReadOnlyMemory(buffer, 0, result.ReceivedBytes); + + if (data.Length is 74 && data.Span[0..2].SequenceEqual(new byte[] { 0x00, 0x01, })) + { + await HandleIpDiscoveryAsync(sourceSocket, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); + continue; + } + + Console.WriteLine(data.Length); + + await destinationSocket + .SendAsync(data, SocketFlags.None, cancellationToken) + .ConfigureAwait(false); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs index c853b945..f40d24de 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandler.cs @@ -37,6 +37,7 @@ public VoiceConnectionHandler( public async ValueTask ProcessAsync( VoiceConnectionContext connectionContext, + IVoiceConnectionHandle connectionHandle, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -110,8 +111,8 @@ await _protocolHandler .WriteAsync(remoteConnectionContext, remoteIdentifyPayload, cancellationToken) .ConfigureAwait(false); - var task1 = ProxyAsync(connectionContext, remoteConnectionContext, cancellationToken).AsTask(); - var task2 = ProxyAsync(remoteConnectionContext, connectionContext, cancellationToken).AsTask(); + var task1 = ProxyAsync(connectionContext, remoteConnectionContext, connectionHandle, isRemote: false, cancellationToken).AsTask(); + var task2 = ProxyAsync(remoteConnectionContext, connectionContext, connectionHandle, isRemote: true, cancellationToken).AsTask(); await Task .WhenAny(task1, task2) @@ -121,6 +122,8 @@ await Task private async ValueTask ProxyAsync( VoiceConnectionContext sourceConnectionContext, VoiceConnectionContext destinationConnectionContext, + IVoiceConnectionHandle connectionHandle, + bool isRemote = false, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -141,8 +144,53 @@ private async ValueTask ProxyAsync( break; } + var receivedPayload = result.Payload; + + switch (receivedPayload) + { + case ReadyPayload payload when isRemote: + var localEndPoint = await connectionHandle + .SetReadyAsync(payload, cancellationToken) + .ConfigureAwait(false); + + receivedPayload = new ReadyPayload + { + Ssrc = payload.Ssrc, + Ip = localEndPoint.Address.ToString(), + Port = localEndPoint.Port, + Modes = payload.Modes, + }; + + break; + + case SessionDescriptionPayload payload when isRemote: + await connectionHandle + .SetSessionDescriptionAsync(payload, cancellationToken) + .ConfigureAwait(false); + + break; + + case SelectProtocolPayload payload when !isRemote: + var remoteEndPoint = await connectionHandle + .SelectProtocolAsync(payload, cancellationToken) + .ConfigureAwait(false); + + receivedPayload = new SelectProtocolPayload + { + Data = new SelectProtocolData + { + Address = remoteEndPoint.Address.ToString(), + Port = remoteEndPoint.Port, + Mode = payload.Data.Mode, + }, + Protocol = payload.Protocol, + }; + + break; + } + await _protocolHandler - .WriteAsync(destinationConnectionContext, result.Payload, cancellationToken) + .WriteAsync(destinationConnectionContext, receivedPayload, cancellationToken) .ConfigureAwait(false); } } diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs index 8208395c..0a561456 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceProtocolHandler.cs @@ -66,7 +66,7 @@ await connectionContext.WebSocket if (!result.EndOfMessage) { - _logger.LogWarning(label, "[{Label}] Received a partial payload from voice gateway."); + _logger.LogWarning("[{Label}] Received a partial payload from voice gateway.", label); await connectionContext.WebSocket .CloseAsync(WebSocketCloseStatus.MessageTooBig, "Payload is too large.", cancellationToken) diff --git a/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs b/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs index 3e4753cb..41d08ed7 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Extensions/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ namespace Lavalink4NET.Experiments.Receive.Extensions; using Lavalink4NET.Experiments.Receive.Connections; +using Lavalink4NET.Experiments.Receive.Connections.Discovery; using Lavalink4NET.Experiments.Receive.Server; using Lavalink4NET.Experiments.Receive.Sessions; using Lavalink4NET.Players; @@ -17,6 +18,7 @@ public static IServiceCollection AddLavalinkReceive(this IServiceCollection serv services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddSingleton(); services.Configure(static _ => { }); diff --git a/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs index 336292db..ef3bc6ce 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Server/LavalinkVoiceServer.cs @@ -4,6 +4,7 @@ using System.Diagnostics.Metrics; using System.Globalization; using Lavalink4NET.Experiments.Receive.Connections; +using Lavalink4NET.Experiments.Receive.Connections.Discovery; using Lavalink4NET.Experiments.Receive.Connections.Features; using Microsoft.AspNetCore.Connections.Features; using Microsoft.AspNetCore.Hosting.Server; @@ -18,18 +19,20 @@ internal sealed class LavalinkVoiceServer : IHttpApplication, ILava { private readonly IServiceProvider _serviceProvider; private readonly IVoiceConnectionHandler _voiceConnectionHandler; + private readonly IIpDiscoveryService _ipDiscoveryService; private readonly IServer _server; private readonly WebSocketMiddleware _webSocketMiddleware; public LavalinkVoiceServer( IVoiceConnectionHandler voiceConnectionHandler, + IIpDiscoveryService ipDiscoveryService, ILoggerFactory loggerFactory, IOptions options) { ArgumentNullException.ThrowIfNull(voiceConnectionHandler); _voiceConnectionHandler = voiceConnectionHandler; - + _ipDiscoveryService = ipDiscoveryService; var services = new ServiceCollection(); // HTTP Kestrel Host @@ -140,7 +143,7 @@ await httpContext.Response connectionContext.Features.Set(new VoiceGatewayVersionFeature(version)); await _voiceConnectionHandler - .ProcessAsync(connectionContext, cancellationToken) + .ProcessAsync(connectionContext, new VoiceConnectionHandle(_ipDiscoveryService), cancellationToken) .ConfigureAwait(false); } } From 799755854d0cd02c5b3b1adba2751253f8631095 Mon Sep 17 00:00:00 2001 From: Angelo Breuer <46497296+angelobreuer@users.noreply.github.com> Date: Sun, 28 Apr 2024 22:22:19 +0200 Subject: [PATCH 19/19] feat: Implement IP discovery binding --- .../Connections/VoiceConnectionHandle.cs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandle.cs b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandle.cs index 78fa4429..72a9af9a 100644 --- a/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandle.cs +++ b/experiments/Lavalink4NET.Experiments.Receive/Connections/VoiceConnectionHandle.cs @@ -88,13 +88,12 @@ private ValueTask CompleteAsync(CancellationToken cancellationToken return default; } - _localSocket!.Connect(new IPEndPoint(IPAddress.Parse(_selectProtocolPayload.Data.Address), _selectProtocolPayload.Data.Port)); _ = ProxyAsync(_remoteSocket!, _localSocket!, cancellationToken).AsTask(); return default; } - private async ValueTask HandleIpDiscoveryAsync(Socket sourceSocket, EndPoint endPoint, CancellationToken cancellationToken = default) + private async ValueTask HandleIpDiscoveryAsync(Socket sourceSocket, IPEndPoint endPoint, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); ArgumentNullException.ThrowIfNull(sourceSocket); @@ -109,21 +108,24 @@ private async ValueTask HandleIpDiscoveryAsync(Socket sourceSocket, EndPoint end bufferWriter.Advance(8); - var localEndPoint = (IPEndPoint)sourceSocket.LocalEndPoint!; - // Encode IP var ipContent = bufferWriter.GetMemory(64); - var encodedByteCount = Encoding.UTF8.GetBytes(localEndPoint.Address.ToString(), ipContent.Span); + var encodedByteCount = Encoding.UTF8.GetBytes(endPoint.Address.ToString(), ipContent.Span); ipContent.Span[encodedByteCount] = 0; bufferWriter.Advance(64); // Encode port var portContent = bufferWriter.GetMemory(2); - BinaryPrimitives.WriteUInt16BigEndian(portContent.Span, (ushort)localEndPoint.Port); + BinaryPrimitives.WriteUInt16BigEndian(portContent.Span, (ushort)endPoint.Port); bufferWriter.Advance(2); + if (!sourceSocket.Connected) + { + sourceSocket!.Connect(endPoint); + } + await sourceSocket - .SendToAsync(bufferWriter.WrittenMemory, SocketFlags.None, endPoint, cancellationToken) + .SendAsync(bufferWriter.WrittenMemory, SocketFlags.None, cancellationToken) .ConfigureAwait(false); } @@ -150,14 +152,12 @@ private async ValueTask ProxyAsync(Socket sourceSocket, Socket destinationSocket var data = new ReadOnlyMemory(buffer, 0, result.ReceivedBytes); - if (data.Length is 74 && data.Span[0..2].SequenceEqual(new byte[] { 0x00, 0x01, })) + if (sourceSocket == _localSocket && data.Length is 74 && data.Span[0..2].SequenceEqual(new byte[] { 0x00, 0x01, })) { - await HandleIpDiscoveryAsync(sourceSocket, result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); + await HandleIpDiscoveryAsync(sourceSocket, (IPEndPoint)result.RemoteEndPoint, cancellationToken).ConfigureAwait(false); continue; } - Console.WriteLine(data.Length); - await destinationSocket .SendAsync(data, SocketFlags.None, cancellationToken) .ConfigureAwait(false);