From 7ce92f52b4515b16b1c53af3875abe007d625026 Mon Sep 17 00:00:00 2001 From: Standley Gury Date: Mon, 13 Jul 2026 01:38:31 +0800 Subject: [PATCH] feat: Introduce IGuildMusicService for guild-specific music management - Added IGuildMusicService interface to handle music playback per guild. - Implemented GuildPlayer and GuildPlayerManager to manage music queues and playback for multiple guilds concurrently. - Updated AdminCommands and MusicActionCommands to utilize the new IGuildMusicService for handling music actions. - Removed IMusicPlayerController as it was replaced by the new guild-specific service. - Enhanced event handling for Skip and Stop events to be guild-scoped. - Updated dependency injection to register the new services and components. - Added unit tests for GuildPlayerManager and GuildPlayer to ensure correct functionality. --- CLAUDE.md | 19 +- README.md | 25 +- .../Interfaces/Services/IGuildMusicService.cs | 46 ++++ .../Services/IMusicPlayerController.cs | 18 -- src/Domain/Common/Constants.cs | 4 +- src/Infrastructure/Commands/AdminCommands.cs | 12 +- .../Commands/MusicActionCommands.cs | 12 +- .../Interaction/NetCordInteraction.cs | 6 +- ...yerBackgroundService.cs => GuildPlayer.cs} | 19 +- .../Services/GuildPlayerManager.cs | 184 +++++++++++++++ src/Infrastructure/Services/PlayerHandler.cs | 16 +- src/Tests/Unit/EventingTests.cs | 16 ++ src/Tests/Unit/GuildPlayerManagerTests.cs | 214 ++++++++++++++++++ ...undServiceTests.cs => GuildPlayerTests.cs} | 60 +++-- src/Worker/DependencyInjection.cs | 25 +- 15 files changed, 582 insertions(+), 94 deletions(-) create mode 100644 src/Application/Interfaces/Services/IGuildMusicService.cs delete mode 100644 src/Application/Interfaces/Services/IMusicPlayerController.cs rename src/Infrastructure/Services/{MusicPlayerBackgroundService.cs => GuildPlayer.cs} (90%) create mode 100644 src/Infrastructure/Services/GuildPlayerManager.cs create mode 100644 src/Tests/Unit/GuildPlayerManagerTests.cs rename src/Tests/Unit/{MusicPlayerBackgroundServiceTests.cs => GuildPlayerTests.cs} (83%) diff --git a/CLAUDE.md b/CLAUDE.md index a21c91c..3dc8dcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Discord Music Bot - A .NET 10 application that plays music in Discord voice channels from YouTube, SoundCloud, Spotify (playlist metadata only), and radio streams. Ships with a React dashboard for stats and radio-source management. Designed to run on Linux via Docker; native voice dependencies (libdave, libsodium, libopus) make local Windows runs impractical. +Discord Music Bot - A .NET 10 application that plays music in Discord voice channels from YouTube, SoundCloud, Spotify (playlist metadata only), and radio streams. Supports multiple servers concurrently: each guild gets its own queue, player loop, voice connection, and FFmpeg process, while statistics and the blacklist are shared globally. Ships with a React dashboard for stats and radio-source management. Designed to run on Linux via Docker; native voice dependencies (libdave, libsodium, libopus) make local Windows runs impractical. ## Build & Run Commands @@ -47,18 +47,18 @@ Tests live in `src/Tests/` (xUnit + NSubstitute; integration tests use Testconta - **Domain** (`src/Domain/`) - Entities (`Song`, `User`, `PlayHistory`, `RadioSource`), event handler interfaces (`IEventHandler`, `IAsyncEventHandler`), and enums. - **Application** (`src/Application/`) - Business services (`SpotifyService`, `JokeService`, `QuoteService`, `HttpRequestService`), DTOs, config binding helpers, the in-process event dispatcher plus `AddEventing` registration (`Application.Eventing`), and the `GlobalStore` singleton. -- **Infrastructure** (`src/Infrastructure/`) - NetCord commands and interactions, audio pipeline (`MusicPlayerBackgroundService`, `AudioPlayerService`, `FfmpegProcessService`, `MusicQueueService`), `PlayerHandler` (thin Skip/Stop event adapter), YouTube/SoundCloud stream services, EF Core `DiscordBotContext` with compiled models, and radio/user/blacklist/statistics services. -- **Tests** (`src/Tests/`) - xUnit test project: `Unit/` (queue, player background service, eventing) and `Integration/` (blacklist/statistics/user services against Testcontainers PostgreSQL). +- **Infrastructure** (`src/Infrastructure/`) - NetCord commands and interactions, audio pipeline (`GuildPlayerManager`, `GuildPlayer`, `AudioPlayerService`, `FfmpegProcessService`, `MusicQueueService`), `PlayerHandler` (thin guild-scoped Skip/Stop event adapter), YouTube/SoundCloud stream services, EF Core `DiscordBotContext` with compiled models, and radio/user/blacklist/statistics services. +- **Tests** (`src/Tests/`) - xUnit test project: `Unit/` (queue, guild player, guild player manager, eventing) and `Integration/` (blacklist/statistics/user services against Testcontainers PostgreSQL). - **UI** (`src/UI/`) - `Api/` - ASP.NET Core minimal-API endpoints (see `ControllerExtensions.cs`) with JWT bearer auth. Login checks against `JwtSettings:InternalPassword` (no user password hashing). - `App/` - React 19 + TypeScript SPA. Build output writes into `src/Worker/wwwroot/`, which is served by the Worker as static files with SPA fallback to `index.html`. -- **Worker** (`src/Worker/`) - Composition root. Program.cs builds a `WebApplication` that hosts the NetCord Discord gateway (registered via `AddDiscordGateway`), the ASP.NET Core web API, and the `MusicPlayerBackgroundService` hosted service in one process on port 5000. +- **Worker** (`src/Worker/`) - Composition root. Program.cs builds a `WebApplication` that hosts the NetCord Discord gateway (registered via `AddDiscordGateway`), the ASP.NET Core web API, and the `GuildPlayerManager` hosted service in one process on port 5000. ### Key Patterns -**Event system**: Custom in-process dispatcher (`IEventDispatcher`, `IAsyncEventDispatcher`) plus a `HandlerRegistry`. `Application.Eventing.EventingServiceCollectionExtensions.AddEventing(assemblies...)` scans supplied assemblies for `IEventHandler` / `IAsyncEventHandler` implementations and registers them as scoped. When adding a new event handler, ensure its assembly is passed to `AddEventing` in `DependencyInjection.cs` (currently `Application.AssemblyMarker` and `Infrastructure.Services.AssemblyMarker`). Only `EventType.Skip` / `EventType.Stop` are dispatched today (handled by `PlayerHandler`, which forwards to `IMusicPlayerController`); `EventType.Play` is not dispatched anymore because enqueueing wakes the player's channel consumer directly. +**Event system**: Custom in-process dispatcher (`IEventDispatcher`, `IAsyncEventDispatcher`) plus a `HandlerRegistry`. `Application.Eventing.EventingServiceCollectionExtensions.AddEventing(assemblies...)` scans supplied assemblies for `IEventHandler` / `IAsyncEventHandler` implementations and registers them as scoped. When adding a new event handler, ensure its assembly is passed to `AddEventing` in `DependencyInjection.cs` (currently `Application.AssemblyMarker` and `Infrastructure.Services.AssemblyMarker`). Only `EventType.Skip` / `EventType.Stop` are dispatched today; both carry a `GuildId` and are handled by `PlayerHandler`, which forwards to `IGuildMusicService`. `EventType.Play` is not dispatched anymore because enqueueing wakes the guild's channel consumer directly. -**Queue service pattern**: `MusicQueueService` (singleton) pairs a lock-protected list with an unbounded `System.Threading.Channels` signal channel, following the Microsoft queue-service guidance. `MusicPlayerBackgroundService` (a `BackgroundService`, also registered as `IMusicPlayerController`) is the single consumer: it dequeues a `PlayRequest` into the `NowPlaying` slot, awaits `AudioPlayerService.PlayTrackAsync` (retrying failed tracks up to 3 times), and disconnects from voice when the queue runs empty. Skip/Stop cancel the per-track linked `CancellationTokenSource`. +**Per-guild players**: `GuildPlayerManager` (a `BackgroundService`, registered as `IGuildMusicService`) owns one `GuildPlayer` per guild, created lazily on the first enqueue for that guild via a component factory (wired in `Worker/DependencyInjection.cs`). Each `GuildPlayer` owns its own `MusicQueueService` (a lock-protected list paired with an unbounded `System.Threading.Channels` signal channel, per the Microsoft queue-service guidance), its own `AudioPlayerService` (voice client) and `FfmpegProcessService` instance, and a consumer loop: it dequeues a `PlayRequest` into the `NowPlaying` slot, awaits `AudioPlayerService.PlayTrackAsync` (retrying failed tracks up to 3 times), and disconnects from that guild's voice channel when its queue runs empty. Skip/Stop cancel the guild's per-track linked `CancellationTokenSource`; commands address a guild through `IGuildMusicService` with `Context.Guild.Id`. None of `MusicQueueService`, `AudioPlayerService`, or `FfmpegProcessService` are DI-registered anymore - the manager constructs them per guild. **Keyed services**: Multiple implementations of `IStreamService` and `IRandomService` are registered by name and resolved with `[FromKeyedServices(nameof(...))]`: @@ -70,9 +70,10 @@ services.AddKeyedScoped(nameof(QuoteService)); ``` **Discord commands** (`src/Infrastructure/Commands/`): + - `PlayCommand` (in `MusicPlayCommands.cs`) - `/play music` and radio subcommands -- `MusicActionCommands` - pause, skip, stop, volume -- `AdminCommands` - admin-only actions +- `MusicActionCommands` - stop, skip, playlist, rewind, statistics +- `AdminCommands` - admin-only actions (blacklist management) - `MiscCommands` - help, joke, motivate Commands and the `NetCordInteraction` component module are wired in `Worker.DependencyInjection.AddWebApplication`. @@ -85,7 +86,7 @@ PostgreSQL + EF Core 10. Context: `Infrastructure/Data/DiscordBotContext.cs`. Us ### Audio Pipeline -A play interaction enqueues a `PlayRequest` into `MusicQueueService`; the channel signal wakes `MusicPlayerBackgroundService`, which calls `AudioPlayerService.PlayTrackAsync`. That method joins the voice channel if needed, resolves the stream URL (`YoutubeExplode` / `YoutubeDLSharp` via `yt-dlp` at runtime, or a radio source URL when the selection is a Guid), logs the play via `IStatisticsService` (radio plays are logged with the station name as the title), spawns FFmpeg through `FfmpegProcessService` (`Ffmpeg:Path` config, default `/usr/bin/ffmpeg`), copies FFmpeg stdout into a NetCord `OpusEncodeStream`, then awaits process exit and maps the exit code to a `TrackPlayResult` (`Completed`/`Failed`/`Skipped`/`NotInVoiceChannel`). There are no C# events in this pipeline; user-facing messages flow through `PlayRequest.Callbacks` and failures are retried by the background service. +A play interaction enqueues a `PlayRequest` via `IGuildMusicService` into the guild's `MusicQueueService`; the channel signal wakes that guild's `GuildPlayer` loop, which calls `AudioPlayerService.PlayTrackAsync`. That method joins the voice channel if needed, resolves the stream URL (`YoutubeExplode` / `YoutubeDLSharp` via `yt-dlp` at runtime, or a radio source URL when the selection is a Guid), logs the play via `IStatisticsService` (radio plays are logged with the station name as the title), spawns FFmpeg through `FfmpegProcessService` (`Ffmpeg:Path` config, default `/usr/bin/ffmpeg`), copies FFmpeg stdout into a NetCord `OpusEncodeStream`, then awaits process exit and maps the exit code to a `TrackPlayResult` (`Completed`/`Failed`/`Skipped`/`NotInVoiceChannel`). There are no C# events in this pipeline; user-facing messages flow through `PlayRequest.Callbacks` and failures are retried by the guild's player loop. ### Native Dependencies (installed in the Docker image) diff --git a/README.md b/README.md index e20ef89..0e2463b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Discord Music Bot -A .NET 10 Discord bot for music playback, supporting various audio sources and radio streams. Includes a React-based web dashboard. +A .NET 10 Discord bot for music playback, supporting YouTube, SoundCloud, Spotify (playlist metadata), and radio streams. Supports multiple servers concurrently - each server gets its own queue, player, and voice connection. Includes a React-based web dashboard for statistics and radio-source management. **Note:** This application is designed to run on Linux via Docker. A Dockerfile and docker-compose setup are provided. @@ -16,6 +16,21 @@ A .NET 10 Discord bot for music playback, supporting various audio sources and r docker-compose -f deployment/docker-compose.yml up --build ``` +## Development + +```bash +# Build the whole solution (requires Bun for the frontend build) +dotnet build discord-project.slnx + +# Run all tests (integration tests need a local Docker daemon for Testcontainers) +dotnet test src/Tests/Tests.csproj + +# Unit tests only (no Docker required) +dotnet test src/Tests/Tests.csproj --filter "FullyQualifiedName~Tests.Unit" +``` + +Tests run automatically in CI (`.github/workflows/ci.yml`) on pushes and pull requests to `master`; pushes to `master` are deployed via `.github/workflows/release.yml`. + ## Technologies Used ### Backend @@ -24,11 +39,13 @@ A .NET 10 Discord bot for music playback, supporting various audio sources and r - [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) + [PostgreSQL](https://www.postgresql.org) - [YoutubeExplode](https://github.com/Tyrrrz/YoutubeExplode) / [YoutubeDLSharp](https://github.com/Bluegrams/YoutubeDLSharp) + [yt-dlp](https://github.com/yt-dlp/yt-dlp) - [SoundCloudExplode](https://github.com/jerry08/SoundCloudExplode) -- [NAudio](https://github.com/naudio/NAudio) / [FFmpeg](https://ffmpeg.org) - audio processing -- [libopus](https://opus-codec.org) / [libsodium](https://doc.libsodium.org) / [libdave](https://github.com/discord/libdave) - voice encryption and encoding +- [Spotify Web API](https://developer.spotify.com/documentation/web-api) - playlist metadata +- [FFmpeg](https://ffmpeg.org) - audio processing +- [libopus](https://opus-codec.org) / [libsodium](https://doc.libsodium.org) / [libdave](https://github.com/discord/libdave) - voice encoding and encryption +- [xUnit](https://xunit.net) + [NSubstitute](https://nsubstitute.github.io) + [Testcontainers](https://dotnet.testcontainers.org) - testing ### Frontend - [React 19](https://react.dev) with TypeScript - [TanStack Router](https://tanstack.com/router) / [TanStack Query](https://tanstack.com/query) - [Recharts](https://recharts.org) -- [Vite](https://vite.dev) + [Bun](https://bun.sh) \ No newline at end of file +- [Vite](https://vite.dev) + [Bun](https://bun.sh) diff --git a/src/Application/Interfaces/Services/IGuildMusicService.cs b/src/Application/Interfaces/Services/IGuildMusicService.cs new file mode 100644 index 0000000..3339ccd --- /dev/null +++ b/src/Application/Interfaces/Services/IGuildMusicService.cs @@ -0,0 +1,46 @@ +using Application.DTOs; + +namespace Application.Interfaces.Services; + +/// +/// Guild-scoped facade over the per-guild music players. Commands and interactions +/// address a specific guild's queue and playback through this interface; players are +/// created lazily on the first enqueue for a guild. +/// +public interface IGuildMusicService +{ + void Enqueue(ulong guildId, PlayRequest request); + + /// + /// The request currently being played in the guild, or null when the guild has no + /// player or nothing is playing. + /// + PlayRequest? GetNowPlaying(ulong guildId); + + /// + /// Snapshot of the guild's queue: the currently playing request first (if any), + /// then pending ones. Empty when the guild has no player. + /// + PlayRequest[] GetAllRequests(ulong guildId); + + /// + /// Number of pending requests in the guild (excludes the currently playing one). + /// + int GetQueueCount(ulong guildId); + + /// + /// Re-queues the guild's currently playing request at the front so it plays again. + /// + void Rewind(ulong guildId); + + /// + /// Cancels the guild's current track. No-op when the guild has no player. + /// + void Skip(ulong guildId); + + /// + /// Clears the guild's queue and cancels its current track. No-op when the guild + /// has no player. + /// + void Stop(ulong guildId); +} diff --git a/src/Application/Interfaces/Services/IMusicPlayerController.cs b/src/Application/Interfaces/Services/IMusicPlayerController.cs deleted file mode 100644 index 3079409..0000000 --- a/src/Application/Interfaces/Services/IMusicPlayerController.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Application.Interfaces.Services; - -/// -/// Control surface for the music player background service. -/// -public interface IMusicPlayerController -{ - /// - /// Cancels the currently playing track, if any. The player advances to the next queued track. - /// - void Skip(); - - /// - /// Clears the pending queue and cancels the currently playing track. - /// The player disconnects from the voice channel once idle. - /// - void Stop(); -} diff --git a/src/Domain/Common/Constants.cs b/src/Domain/Common/Constants.cs index 84cab80..be6a5bb 100644 --- a/src/Domain/Common/Constants.cs +++ b/src/Domain/Common/Constants.cs @@ -17,8 +17,8 @@ public class EventType { public record Play : IEvent; public record PlayListPlay : IEvent; - public record Stop : IEvent; - public record Skip : IEvent; + public record Stop(ulong GuildId) : IEvent; + public record Skip(ulong GuildId) : IEvent; } public enum AudioSource diff --git a/src/Infrastructure/Commands/AdminCommands.cs b/src/Infrastructure/Commands/AdminCommands.cs index 3444f2e..69cf5f1 100644 --- a/src/Infrastructure/Commands/AdminCommands.cs +++ b/src/Infrastructure/Commands/AdminCommands.cs @@ -17,13 +17,19 @@ namespace Infrastructure.Commands; public class AdminCommands( [FromKeyedServices(nameof(YoutubeService))] IStreamService youtubeService, - IMusicQueueService queue, + IGuildMusicService guildMusicService, IServiceProvider serviceProvider) : ApplicationCommandModule { [SubSlashCommand("blacklist", "Blacklist the currently playing song")] public async Task BlacklistAsync() { - var song = queue.NowPlaying; + if (Context.Guild is null) + { + await RespondAsync(InteractionCallback.Message("This command can only be used in a server.")); + return; + } + + var song = guildMusicService.GetNowPlaying(Context.Guild.Id); if (song is null) { await RespondAsync(InteractionCallback.Message( @@ -61,7 +67,7 @@ await RespondAsync(InteractionCallback.Message( // Skip the blacklisted track; the player disconnects on its own when the queue is empty. var eventDispatcher = scope.ServiceProvider.GetRequiredService(); - eventDispatcher.Dispatch(new EventType.Skip()); + eventDispatcher.Dispatch(new EventType.Skip(Context.Guild.Id)); } [SubSlashCommand("unblacklist", "Remove a song from the blacklist")] diff --git a/src/Infrastructure/Commands/MusicActionCommands.cs b/src/Infrastructure/Commands/MusicActionCommands.cs index 6d2d0a3..ed02949 100644 --- a/src/Infrastructure/Commands/MusicActionCommands.cs +++ b/src/Infrastructure/Commands/MusicActionCommands.cs @@ -10,7 +10,7 @@ namespace Infrastructure.Commands; -public class MusicActionCommands(IScopeExecutor executor, IMusicQueueService queue) +public class MusicActionCommands(IScopeExecutor executor, IGuildMusicService guildMusicService) : ApplicationCommandModule { [SlashCommand("stop", "Stop playing and clear the queue")] @@ -21,7 +21,7 @@ public async Task Stop() return; } - DispatchEvent(new EventType.Stop()); + DispatchEvent(new EventType.Stop(Context.Guild!.Id)); var message = CommandUtils.CreateMessage("Stopping playback and clearing the queue."); await RespondAsync(InteractionCallback.Message(message)); @@ -35,7 +35,7 @@ public async Task Skip() return; } - DispatchEvent(new EventType.Skip()); + DispatchEvent(new EventType.Skip(Context.Guild!.Id)); var message = CommandUtils.CreateMessage("Skipping the current track."); await RespondAsync(InteractionCallback.Message(message)); } @@ -48,7 +48,7 @@ public async Task Playlist() return; } - var requests = queue.GetAllRequests(); + var requests = guildMusicService.GetAllRequests(Context.Guild!.Id); if (requests.Length == 0) await RespondAsync(InteractionCallback.Message("No songs in queue.")); else @@ -87,14 +87,14 @@ public async Task Rewind() } InteractionMessageProperties message; - if(queue.NowPlaying is null) + if (guildMusicService.GetNowPlaying(Context.Guild!.Id) is null) { message = CommandUtils.CreateMessage("No songs in queue."); await RespondAsync(InteractionCallback.Message(message)); return; } - queue.Rewind(); + guildMusicService.Rewind(Context.Guild.Id); message = CommandUtils.CreateMessage("Rewinding the current track."); await RespondAsync(InteractionCallback.Message(message)); } diff --git a/src/Infrastructure/Interaction/NetCordInteraction.cs b/src/Infrastructure/Interaction/NetCordInteraction.cs index d89ad2f..1465b7c 100644 --- a/src/Infrastructure/Interaction/NetCordInteraction.cs +++ b/src/Infrastructure/Interaction/NetCordInteraction.cs @@ -13,7 +13,7 @@ namespace Infrastructure.Interaction; public class NetCordInteraction( ILogger logger, - IMusicQueueService queueService, + IGuildMusicService guildMusicService, IBlacklistService blacklistService, YoutubeClient youtubeClient, [FromKeyedServices(nameof(YoutubeService))] IStreamService youtubeService) : ComponentInteractionModule @@ -53,7 +53,7 @@ public async Task Play() Context = Context, Callbacks = async callbackMessage => await RespondAsyncCallback(callbackMessage), }; - queueService.Enqueue(playRequest); + guildMusicService.Enqueue(Context.Guild!.Id, playRequest); return message; } @@ -91,7 +91,7 @@ public async Task PlayPlaylist() VideoTitle = video.Title, VideoUrl = video.Url }; - queueService.Enqueue(playRequest); + guildMusicService.Enqueue(Context.Guild!.Id, playRequest); added++; } diff --git a/src/Infrastructure/Services/MusicPlayerBackgroundService.cs b/src/Infrastructure/Services/GuildPlayer.cs similarity index 90% rename from src/Infrastructure/Services/MusicPlayerBackgroundService.cs rename to src/Infrastructure/Services/GuildPlayer.cs index 297dbe1..62dac82 100644 --- a/src/Infrastructure/Services/MusicPlayerBackgroundService.cs +++ b/src/Infrastructure/Services/GuildPlayer.cs @@ -1,31 +1,32 @@ using Application.DTOs; using Application.Interfaces.Services; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using NetCord.Services.ComponentInteractions; namespace Infrastructure.Services; /// -/// Single consumer of the music queue (queue service pattern from +/// Owns playback for a single guild: the single consumer of that guild's music queue +/// (queue service pattern from /// https://learn.microsoft.com/en-us/dotnet/core/extensions/queue-service). /// Dequeues one request at a time, plays it to completion via -/// , retries failed tracks, and -/// disconnects from the voice channel when the queue runs empty. -/// Skip/Stop are signalled through . +/// , retries failed tracks, and disconnects +/// from the guild's voice channel when the queue runs empty. +/// Created and driven by . /// -public sealed class MusicPlayerBackgroundService( +public sealed class GuildPlayer( IMusicQueueService queue, INetCordAudioPlayerService audioPlayer, - ILogger logger) - : BackgroundService, IMusicPlayerController + ILogger logger) { private const int MaxRetryCount = 3; private readonly Lock _ctsLock = new(); private CancellationTokenSource? _trackCts; - protected override async Task ExecuteAsync(CancellationToken stoppingToken) + public IMusicQueueService Queue => queue; + + public async Task RunAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { diff --git a/src/Infrastructure/Services/GuildPlayerManager.cs b/src/Infrastructure/Services/GuildPlayerManager.cs new file mode 100644 index 0000000..da266e0 --- /dev/null +++ b/src/Infrastructure/Services/GuildPlayerManager.cs @@ -0,0 +1,184 @@ +using System.Collections.Concurrent; +using Application.DTOs; +using Application.Interfaces.Services; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Services; + +/// +/// Components backing one guild's . The optional disposable +/// (the guild's FFmpeg process service in production) is disposed when the manager +/// shuts down. +/// +public sealed record GuildPlayerComponents( + IMusicQueueService Queue, + INetCordAudioPlayerService AudioPlayer, + IDisposable? Disposable = null); + +/// +/// Owns one per guild so multiple servers can play music +/// concurrently. Players (queue + consumer loop + audio player + FFmpeg service) are +/// created lazily on the first enqueue for a guild and kept for the process lifetime; +/// all loops are cancelled and per-guild components disposed on host shutdown. +/// +public sealed class GuildPlayerManager( + ILoggerFactory loggerFactory, + Func componentFactory) + : BackgroundService, IGuildMusicService +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private readonly ConcurrentDictionary _players = new(); + private readonly List _loops = []; + private readonly List _disposables = []; + private readonly Lock _createLock = new(); + private readonly CancellationTokenSource _stoppingCts = new(); + private bool _disposed; + + public void Enqueue(ulong guildId, PlayRequest request) => + GetOrCreatePlayer(guildId).Queue.Enqueue(request); + + public PlayRequest? GetNowPlaying(ulong guildId) => + _players.TryGetValue(guildId, out var player) ? player.Queue.NowPlaying : null; + + public PlayRequest[] GetAllRequests(ulong guildId) => + _players.TryGetValue(guildId, out var player) ? player.Queue.GetAllRequests() : []; + + public int GetQueueCount(ulong guildId) => + _players.TryGetValue(guildId, out var player) ? player.Queue.Count : 0; + + public void Rewind(ulong guildId) + { + if (_players.TryGetValue(guildId, out var player)) + { + player.Queue.Rewind(); + } + } + + public void Skip(ulong guildId) + { + if (_players.TryGetValue(guildId, out var player)) + { + player.Skip(); + } + } + + public void Stop(ulong guildId) + { + if (_players.TryGetValue(guildId, out var player)) + { + player.Stop(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Players run on their own tracked loop tasks; this just parks until shutdown. + try + { + await Task.Delay(Timeout.Infinite, stoppingToken); + } + catch (OperationCanceledException) + { + // Host is shutting down. + } + + await _stoppingCts.CancelAsync(); + + Task[] loops; + lock (_createLock) + { + loops = _loops.ToArray(); + } + + try + { + await Task.WhenAll(loops).WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + } + catch (TimeoutException) + { + _logger.LogWarning("Timed out waiting for guild player loops to stop"); + } + + DisposeComponents(); + } + + private GuildPlayer GetOrCreatePlayer(ulong guildId) + { + if (_players.TryGetValue(guildId, out var existing)) + { + return existing; + } + + // Plain lock instead of GetOrAdd: the factory starts a consumer loop, so it must + // run exactly once per guild. + lock (_createLock) + { + if (_players.TryGetValue(guildId, out existing)) + { + return existing; + } + + var components = componentFactory(guildId); + var player = new GuildPlayer(components.Queue, components.AudioPlayer, + loggerFactory.CreateLogger($"{typeof(GuildPlayer).FullName}[{guildId}]")); + + _loops.Add(RunPlayerLoopAsync(player, guildId)); + if (components.Disposable is not null) + { + _disposables.Add(components.Disposable); + } + + _players[guildId] = player; + _logger.LogInformation("Created music player for guild {GuildId}", guildId); + return player; + } + } + + private async Task RunPlayerLoopAsync(GuildPlayer player, ulong guildId) + { + try + { + await player.RunAsync(_stoppingCts.Token); + } + catch (Exception ex) + { + _logger.LogError(ex, "Music player loop for guild {GuildId} terminated unexpectedly", guildId); + } + } + + private void DisposeComponents() + { + IDisposable[] disposables; + lock (_createLock) + { + disposables = _disposables.ToArray(); + _disposables.Clear(); + } + + foreach (var disposable in disposables) + { + try + { + disposable.Dispose(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error disposing guild player component"); + } + } + } + + public override void Dispose() + { + if (!_disposed) + { + _disposed = true; + _stoppingCts.Cancel(); + _stoppingCts.Dispose(); + DisposeComponents(); + } + + base.Dispose(); + } +} diff --git a/src/Infrastructure/Services/PlayerHandler.cs b/src/Infrastructure/Services/PlayerHandler.cs index a6c16f0..6a55b15 100644 --- a/src/Infrastructure/Services/PlayerHandler.cs +++ b/src/Infrastructure/Services/PlayerHandler.cs @@ -6,21 +6,23 @@ namespace Infrastructure.Services; /// -/// Thin adapter translating Skip/Stop events into player controller signals. -/// Playback itself is driven by . +/// Thin adapter translating guild-scoped Skip/Stop events into player signals. +/// Playback itself is driven by the per-guild loops owned +/// by . /// -public class PlayerHandler(IMusicPlayerController playerController, ILogger logger) +public class PlayerHandler(IGuildMusicService guildMusicService, ILogger logger) : IEventHandler, IEventHandler { public void Handle(EventType.Skip @event) { - logger.LogInformation("Skip event received - cancelling current track"); - playerController.Skip(); + logger.LogInformation("Skip event received for guild {GuildId} - cancelling current track", @event.GuildId); + guildMusicService.Skip(@event.GuildId); } public void Handle(EventType.Stop @event) { - logger.LogInformation("Stop event received - clearing queue and stopping playback"); - playerController.Stop(); + logger.LogInformation("Stop event received for guild {GuildId} - clearing queue and stopping playback", + @event.GuildId); + guildMusicService.Stop(@event.GuildId); } } diff --git a/src/Tests/Unit/EventingTests.cs b/src/Tests/Unit/EventingTests.cs index ddb136f..022f2ea 100644 --- a/src/Tests/Unit/EventingTests.cs +++ b/src/Tests/Unit/EventingTests.cs @@ -1,9 +1,12 @@ using Application.Eventing; +using Application.Interfaces.Services; using Domain.Common; using Domain.Eventing; using Domain.Events; using Infrastructure.Services; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; using Xunit; namespace Tests.Unit; @@ -46,6 +49,19 @@ public void EventDispatcher_Dispatch_Invokes_Registered_Sync_Handler() var recorder = provider.GetRequiredService(); Assert.Equal(2, recorder.Count); } + + [Fact] + public void PlayerHandler_Forwards_Guild_Scoped_Skip_And_Stop() + { + var guildMusicService = Substitute.For(); + var handler = new PlayerHandler(guildMusicService, NullLogger.Instance); + + handler.Handle(new EventType.Skip(42)); + handler.Handle(new EventType.Stop(99)); + + guildMusicService.Received(1).Skip(42); + guildMusicService.Received(1).Stop(99); + } } public sealed record TestEvent : IEvent; diff --git a/src/Tests/Unit/GuildPlayerManagerTests.cs b/src/Tests/Unit/GuildPlayerManagerTests.cs new file mode 100644 index 0000000..f79bff1 --- /dev/null +++ b/src/Tests/Unit/GuildPlayerManagerTests.cs @@ -0,0 +1,214 @@ +using Application.DTOs; +using Application.Interfaces.Services; +using Infrastructure.Services; +using Microsoft.Extensions.Logging.Abstractions; +using NetCord.Services.ComponentInteractions; +using NSubstitute; +using Xunit; + +namespace Tests.Unit; + +public class GuildPlayerManagerTests +{ + private const ulong GuildA = 1111; + private const ulong GuildB = 2222; + + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private static PlayRequest CreateRequest() + { + return new PlayRequest + { + Callbacks = _ => Task.CompletedTask + }; + } + + private static GuildPlayerManager CreateManager( + Func audioPlayerFactory, + Action? onCreate = null) + { + return new GuildPlayerManager(NullLoggerFactory.Instance, guildId => + { + onCreate?.Invoke(guildId); + return new GuildPlayerComponents(new MusicQueueService(), audioPlayerFactory(guildId)); + }); + } + + /// + /// An audio player whose track signals when it starts and then plays until it is + /// either released (Completed) or its token is cancelled (Skipped). + /// + private static INetCordAudioPlayerService CreateBlockingAudioPlayer( + TaskCompletionSource started, TaskCompletionSource release) + { + var audioPlayer = Substitute.For(); + audioPlayer.PlayTrackAsync(Arg.Any(), Arg.Any()) + .Returns(async callInfo => + { + var token = callInfo.Arg(); + started.TrySetResult(); + var cancelled = new TaskCompletionSource(); + await using var registration = token.Register(() => cancelled.TrySetResult()); + var finished = await Task.WhenAny(release.Task, cancelled.Task).WaitAsync(Timeout); + return finished == cancelled.Task ? TrackPlayResult.Skipped : TrackPlayResult.Completed; + }); + return audioPlayer; + } + + private static async Task WaitUntilAsync(Func condition, string description) + { + var start = Environment.TickCount64; + while (!condition()) + { + if (Environment.TickCount64 - start > Timeout.TotalMilliseconds) + { + throw new TimeoutException($"Condition not met within timeout: {description}"); + } + + await Task.Delay(25); + } + } + + [Fact] + public async Task Tracks_In_Different_Guilds_Play_Concurrently() + { + var startedA = new TaskCompletionSource(); + var releaseA = new TaskCompletionSource(); + var startedB = new TaskCompletionSource(); + var releaseB = new TaskCompletionSource(); + + using var manager = CreateManager(guildId => guildId == GuildA + ? CreateBlockingAudioPlayer(startedA, releaseA) + : CreateBlockingAudioPlayer(startedB, releaseB)); + await manager.StartAsync(CancellationToken.None); + try + { + manager.Enqueue(GuildA, CreateRequest()); + await startedA.Task.WaitAsync(Timeout); + + // Guild A's track is still held open; guild B's track must start anyway. + manager.Enqueue(GuildB, CreateRequest()); + await startedB.Task.WaitAsync(Timeout); + + Assert.NotNull(manager.GetNowPlaying(GuildA)); + Assert.NotNull(manager.GetNowPlaying(GuildB)); + + releaseA.TrySetResult(); + releaseB.TrySetResult(); + } + finally + { + await manager.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task Skip_Cancels_Only_That_Guilds_Track() + { + var startedA = new TaskCompletionSource(); + var releaseA = new TaskCompletionSource(); + var startedB = new TaskCompletionSource(); + var releaseB = new TaskCompletionSource(); + + using var manager = CreateManager(guildId => guildId == GuildA + ? CreateBlockingAudioPlayer(startedA, releaseA) + : CreateBlockingAudioPlayer(startedB, releaseB)); + await manager.StartAsync(CancellationToken.None); + try + { + manager.Enqueue(GuildA, CreateRequest()); + manager.Enqueue(GuildB, CreateRequest()); + await startedA.Task.WaitAsync(Timeout); + await startedB.Task.WaitAsync(Timeout); + + manager.Skip(GuildA); + + await WaitUntilAsync(() => manager.GetNowPlaying(GuildA) is null, "guild A track skipped"); + Assert.NotNull(manager.GetNowPlaying(GuildB)); + + releaseB.TrySetResult(); + } + finally + { + await manager.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task Stop_Clears_Only_That_Guilds_Queue() + { + var startedA = new TaskCompletionSource(); + var releaseA = new TaskCompletionSource(); + var startedB = new TaskCompletionSource(); + var releaseB = new TaskCompletionSource(); + + using var manager = CreateManager(guildId => guildId == GuildA + ? CreateBlockingAudioPlayer(startedA, releaseA) + : CreateBlockingAudioPlayer(startedB, releaseB)); + await manager.StartAsync(CancellationToken.None); + try + { + manager.Enqueue(GuildA, CreateRequest()); + manager.Enqueue(GuildA, CreateRequest()); + manager.Enqueue(GuildB, CreateRequest()); + manager.Enqueue(GuildB, CreateRequest()); + await startedA.Task.WaitAsync(Timeout); + await startedB.Task.WaitAsync(Timeout); + + manager.Stop(GuildA); + + await WaitUntilAsync( + () => manager.GetNowPlaying(GuildA) is null && manager.GetQueueCount(GuildA) == 0, + "guild A stopped and cleared"); + + // Guild B is untouched: still playing its first track with one pending. + Assert.NotNull(manager.GetNowPlaying(GuildB)); + Assert.Equal(1, manager.GetQueueCount(GuildB)); + + releaseB.TrySetResult(); + } + finally + { + await manager.StopAsync(CancellationToken.None); + } + } + + [Fact] + public void Operations_On_Unknown_Guild_Are_NoOps() + { + using var manager = CreateManager(_ => Substitute.For()); + + Assert.Null(manager.GetNowPlaying(GuildA)); + Assert.Empty(manager.GetAllRequests(GuildA)); + Assert.Equal(0, manager.GetQueueCount(GuildA)); + + // None of these should throw or create a player. + manager.Skip(GuildA); + manager.Stop(GuildA); + manager.Rewind(GuildA); + + Assert.Null(manager.GetNowPlaying(GuildA)); + } + + [Fact] + public async Task Same_Guild_Reuses_The_Same_Player() + { + var factoryCalls = 0; + using var manager = CreateManager( + _ => Substitute.For(), + _ => Interlocked.Increment(ref factoryCalls)); + await manager.StartAsync(CancellationToken.None); + try + { + manager.Enqueue(GuildA, CreateRequest()); + manager.Enqueue(GuildA, CreateRequest()); + manager.Enqueue(GuildB, CreateRequest()); + + Assert.Equal(2, Volatile.Read(ref factoryCalls)); + } + finally + { + await manager.StopAsync(CancellationToken.None); + } + } +} diff --git a/src/Tests/Unit/MusicPlayerBackgroundServiceTests.cs b/src/Tests/Unit/GuildPlayerTests.cs similarity index 83% rename from src/Tests/Unit/MusicPlayerBackgroundServiceTests.cs rename to src/Tests/Unit/GuildPlayerTests.cs index a5ef9b8..e275112 100644 --- a/src/Tests/Unit/MusicPlayerBackgroundServiceTests.cs +++ b/src/Tests/Unit/GuildPlayerTests.cs @@ -8,7 +8,7 @@ namespace Tests.Unit; -public class MusicPlayerBackgroundServiceTests +public class GuildPlayerTests { private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); @@ -31,11 +31,9 @@ private static PlayRequest CreateRequest(List.Instance); + return new GuildPlayer(queue, audioPlayer, NullLogger.Instance); } private static async Task WaitUntilAsync(Func condition, string description) @@ -74,8 +72,9 @@ public async Task Plays_Queued_Tracks_Sequentially_In_Order() queue.Enqueue(first); queue.Enqueue(second); - var service = CreateService(queue, audioPlayer); - await service.StartAsync(CancellationToken.None); + var player = CreatePlayer(queue, audioPlayer); + using var cts = new CancellationTokenSource(); + var runTask = player.RunAsync(cts.Token); try { await WaitUntilAsync(() => @@ -91,7 +90,8 @@ await WaitUntilAsync(() => } finally { - await service.StopAsync(CancellationToken.None); + await cts.CancelAsync(); + await runTask; } } @@ -110,8 +110,9 @@ public async Task Failed_Track_Is_Retried_Up_To_Three_Attempts_Then_Dropped() queue.Enqueue(CreateRequest()); - var service = CreateService(queue, audioPlayer); - await service.StartAsync(CancellationToken.None); + var player = CreatePlayer(queue, audioPlayer); + using var cts = new CancellationTokenSource(); + var runTask = player.RunAsync(cts.Token); try { await WaitUntilAsync(() => Volatile.Read(ref attempts) == 3, "three play attempts"); @@ -123,7 +124,8 @@ public async Task Failed_Track_Is_Retried_Up_To_Three_Attempts_Then_Dropped() } finally { - await service.StopAsync(CancellationToken.None); + await cts.CancelAsync(); + await runTask; } } @@ -159,19 +161,21 @@ public async Task Skip_Cancels_Current_Track_And_Advances_To_Next() queue.Enqueue(first); queue.Enqueue(second); - var service = CreateService(queue, audioPlayer); - await service.StartAsync(CancellationToken.None); + var player = CreatePlayer(queue, audioPlayer); + using var cts = new CancellationTokenSource(); + var runTask = player.RunAsync(cts.Token); try { await firstStarted.Task.WaitAsync(Timeout); - service.Skip(); + player.Skip(); await playedSecond.Task.WaitAsync(Timeout); } finally { - await service.StopAsync(CancellationToken.None); + await cts.CancelAsync(); + await runTask; } } @@ -204,13 +208,14 @@ public async Task Stop_Clears_Pending_Cancels_Current_And_Disconnects() queue.Enqueue(first); queue.Enqueue(second); - var service = CreateService(queue, audioPlayer); - await service.StartAsync(CancellationToken.None); + var player = CreatePlayer(queue, audioPlayer); + using var cts = new CancellationTokenSource(); + var runTask = player.RunAsync(cts.Token); try { await firstStarted.Task.WaitAsync(Timeout); - service.Stop(); + player.Stop(); await disconnected.Task.WaitAsync(Timeout); Assert.Equal(0, queue.Count); @@ -220,7 +225,8 @@ public async Task Stop_Clears_Pending_Cancels_Current_And_Disconnects() } finally { - await service.StopAsync(CancellationToken.None); + await cts.CancelAsync(); + await runTask; } } @@ -241,8 +247,9 @@ public async Task Disconnects_When_Queue_Empty_After_Last_Track() var messages = new List(); queue.Enqueue(CreateRequest(messages)); - var service = CreateService(queue, audioPlayer); - await service.StartAsync(CancellationToken.None); + var player = CreatePlayer(queue, audioPlayer); + using var cts = new CancellationTokenSource(); + var runTask = player.RunAsync(cts.Token); try { await disconnected.Task.WaitAsync(Timeout); @@ -258,7 +265,8 @@ await WaitUntilAsync(() => } finally { - await service.StopAsync(CancellationToken.None); + await cts.CancelAsync(); + await runTask; } } @@ -273,8 +281,9 @@ public async Task NotInVoiceChannel_Result_Invokes_Request_Callback_And_Does_Not var messages = new List(); queue.Enqueue(CreateRequest(messages)); - var service = CreateService(queue, audioPlayer); - await service.StartAsync(CancellationToken.None); + var player = CreatePlayer(queue, audioPlayer); + using var cts = new CancellationTokenSource(); + var runTask = player.RunAsync(cts.Token); try { await WaitUntilAsync(() => @@ -289,7 +298,8 @@ await WaitUntilAsync(() => } finally { - await service.StopAsync(CancellationToken.None); + await cts.CancelAsync(); + await runTask; } } } diff --git a/src/Worker/DependencyInjection.cs b/src/Worker/DependencyInjection.cs index caca365..ef9b974 100644 --- a/src/Worker/DependencyInjection.cs +++ b/src/Worker/DependencyInjection.cs @@ -48,16 +48,25 @@ public static void AddDiscordServices(this IServiceCollection services, IConfigu services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); - // Player background service: single consumer of the music queue. - // Registered once and exposed both as the hosted service and as the Skip/Stop controller. - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); - services.AddHostedService(sp => sp.GetRequiredService()); + // Guild player manager: one GuildPlayer (queue + consumer loop + voice client + + // FFmpeg process) per guild, created lazily on the first enqueue, so multiple + // servers can play music concurrently. Registered once and exposed both as the + // hosted service and as the guild-scoped music facade used by commands. + services.AddSingleton(sp => + { + var loggerFactory = sp.GetRequiredService(); + var config = sp.GetRequiredService(); + return new GuildPlayerManager(loggerFactory, _ => + { + var ffmpeg = new FfmpegProcessService(loggerFactory.CreateLogger(), config); + var audioPlayer = new AudioPlayerService(ffmpeg, sp, loggerFactory.CreateLogger()); + return new GuildPlayerComponents(new MusicQueueService(), audioPlayer, ffmpeg); + }); + }); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); services.AddScoped(); services.AddScoped();