Skip to content

Commit f206d18

Browse files
authored
Merge pull request #106 from standleypg-dev/feat/105-multi-guilds-support
feat: Introduce IGuildMusicService for guild-specific music management
2 parents 8f8a299 + 7ce92f5 commit f206d18

15 files changed

Lines changed: 582 additions & 94 deletions

File tree

CLAUDE.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## Project Overview
66

7-
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.
7+
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.
88

99
## Build & Run Commands
1010

@@ -47,18 +47,18 @@ Tests live in `src/Tests/` (xUnit + NSubstitute; integration tests use Testconta
4747

4848
- **Domain** (`src/Domain/`) - Entities (`Song`, `User`, `PlayHistory`, `RadioSource`), event handler interfaces (`IEventHandler<T>`, `IAsyncEventHandler<T>`), and enums.
4949
- **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.
50-
- **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.
51-
- **Tests** (`src/Tests/`) - xUnit test project: `Unit/` (queue, player background service, eventing) and `Integration/` (blacklist/statistics/user services against Testcontainers PostgreSQL).
50+
- **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.
51+
- **Tests** (`src/Tests/`) - xUnit test project: `Unit/` (queue, guild player, guild player manager, eventing) and `Integration/` (blacklist/statistics/user services against Testcontainers PostgreSQL).
5252
- **UI** (`src/UI/`)
5353
- `Api/` - ASP.NET Core minimal-API endpoints (see `ControllerExtensions.cs`) with JWT bearer auth. Login checks against `JwtSettings:InternalPassword` (no user password hashing).
5454
- `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`.
55-
- **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.
55+
- **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.
5656

5757
### Key Patterns
5858

59-
**Event system**: Custom in-process dispatcher (`IEventDispatcher`, `IAsyncEventDispatcher`) plus a `HandlerRegistry`. `Application.Eventing.EventingServiceCollectionExtensions.AddEventing(assemblies...)` scans supplied assemblies for `IEventHandler<T>` / `IAsyncEventHandler<T>` 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.
59+
**Event system**: Custom in-process dispatcher (`IEventDispatcher`, `IAsyncEventDispatcher`) plus a `HandlerRegistry`. `Application.Eventing.EventingServiceCollectionExtensions.AddEventing(assemblies...)` scans supplied assemblies for `IEventHandler<T>` / `IAsyncEventHandler<T>` 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.
6060

61-
**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`.
61+
**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.
6262

6363
**Keyed services**: Multiple implementations of `IStreamService` and `IRandomService` are registered by name and resolved with `[FromKeyedServices(nameof(...))]`:
6464

@@ -70,9 +70,10 @@ services.AddKeyedScoped<IRandomService, QuoteService>(nameof(QuoteService));
7070
```
7171

7272
**Discord commands** (`src/Infrastructure/Commands/`):
73+
7374
- `PlayCommand` (in `MusicPlayCommands.cs`) - `/play music` and radio subcommands
74-
- `MusicActionCommands` - pause, skip, stop, volume
75-
- `AdminCommands` - admin-only actions
75+
- `MusicActionCommands` - stop, skip, playlist, rewind, statistics
76+
- `AdminCommands` - admin-only actions (blacklist management)
7677
- `MiscCommands` - help, joke, motivate
7778

7879
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
8586

8687
### Audio Pipeline
8788

88-
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.
89+
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.
8990

9091
### Native Dependencies (installed in the Docker image)
9192

README.md

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Discord Music Bot
22

3-
A .NET 10 Discord bot for music playback, supporting various audio sources and radio streams. Includes a React-based web dashboard.
3+
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.
44

55
**Note:** This application is designed to run on Linux via Docker. A Dockerfile and docker-compose setup are provided.
66

@@ -16,6 +16,21 @@ A .NET 10 Discord bot for music playback, supporting various audio sources and r
1616
docker-compose -f deployment/docker-compose.yml up --build
1717
```
1818

19+
## Development
20+
21+
```bash
22+
# Build the whole solution (requires Bun for the frontend build)
23+
dotnet build discord-project.slnx
24+
25+
# Run all tests (integration tests need a local Docker daemon for Testcontainers)
26+
dotnet test src/Tests/Tests.csproj
27+
28+
# Unit tests only (no Docker required)
29+
dotnet test src/Tests/Tests.csproj --filter "FullyQualifiedName~Tests.Unit"
30+
```
31+
32+
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`.
33+
1934
## Technologies Used
2035

2136
### Backend
@@ -24,11 +39,13 @@ A .NET 10 Discord bot for music playback, supporting various audio sources and r
2439
- [Entity Framework Core](https://learn.microsoft.com/en-us/ef/core/) + [PostgreSQL](https://www.postgresql.org)
2540
- [YoutubeExplode](https://github.com/Tyrrrz/YoutubeExplode) / [YoutubeDLSharp](https://github.com/Bluegrams/YoutubeDLSharp) + [yt-dlp](https://github.com/yt-dlp/yt-dlp)
2641
- [SoundCloudExplode](https://github.com/jerry08/SoundCloudExplode)
27-
- [NAudio](https://github.com/naudio/NAudio) / [FFmpeg](https://ffmpeg.org) - audio processing
28-
- [libopus](https://opus-codec.org) / [libsodium](https://doc.libsodium.org) / [libdave](https://github.com/discord/libdave) - voice encryption and encoding
42+
- [Spotify Web API](https://developer.spotify.com/documentation/web-api) - playlist metadata
43+
- [FFmpeg](https://ffmpeg.org) - audio processing
44+
- [libopus](https://opus-codec.org) / [libsodium](https://doc.libsodium.org) / [libdave](https://github.com/discord/libdave) - voice encoding and encryption
45+
- [xUnit](https://xunit.net) + [NSubstitute](https://nsubstitute.github.io) + [Testcontainers](https://dotnet.testcontainers.org) - testing
2946

3047
### Frontend
3148
- [React 19](https://react.dev) with TypeScript
3249
- [TanStack Router](https://tanstack.com/router) / [TanStack Query](https://tanstack.com/query)
3350
- [Recharts](https://recharts.org)
34-
- [Vite](https://vite.dev) + [Bun](https://bun.sh)
51+
- [Vite](https://vite.dev) + [Bun](https://bun.sh)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using Application.DTOs;
2+
3+
namespace Application.Interfaces.Services;
4+
5+
/// <summary>
6+
/// Guild-scoped facade over the per-guild music players. Commands and interactions
7+
/// address a specific guild's queue and playback through this interface; players are
8+
/// created lazily on the first enqueue for a guild.
9+
/// </summary>
10+
public interface IGuildMusicService
11+
{
12+
void Enqueue<T>(ulong guildId, PlayRequest<T> request);
13+
14+
/// <summary>
15+
/// The request currently being played in the guild, or null when the guild has no
16+
/// player or nothing is playing.
17+
/// </summary>
18+
PlayRequest? GetNowPlaying(ulong guildId);
19+
20+
/// <summary>
21+
/// Snapshot of the guild's queue: the currently playing request first (if any),
22+
/// then pending ones. Empty when the guild has no player.
23+
/// </summary>
24+
PlayRequest[] GetAllRequests(ulong guildId);
25+
26+
/// <summary>
27+
/// Number of pending requests in the guild (excludes the currently playing one).
28+
/// </summary>
29+
int GetQueueCount(ulong guildId);
30+
31+
/// <summary>
32+
/// Re-queues the guild's currently playing request at the front so it plays again.
33+
/// </summary>
34+
void Rewind(ulong guildId);
35+
36+
/// <summary>
37+
/// Cancels the guild's current track. No-op when the guild has no player.
38+
/// </summary>
39+
void Skip(ulong guildId);
40+
41+
/// <summary>
42+
/// Clears the guild's queue and cancels its current track. No-op when the guild
43+
/// has no player.
44+
/// </summary>
45+
void Stop(ulong guildId);
46+
}

src/Application/Interfaces/Services/IMusicPlayerController.cs

Lines changed: 0 additions & 18 deletions
This file was deleted.

src/Domain/Common/Constants.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ public class EventType
1717
{
1818
public record Play : IEvent;
1919
public record PlayListPlay : IEvent;
20-
public record Stop : IEvent;
21-
public record Skip : IEvent;
20+
public record Stop(ulong GuildId) : IEvent;
21+
public record Skip(ulong GuildId) : IEvent;
2222
}
2323

2424
public enum AudioSource

src/Infrastructure/Commands/AdminCommands.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,19 @@ namespace Infrastructure.Commands;
1717
public class AdminCommands(
1818
[FromKeyedServices(nameof(YoutubeService))]
1919
IStreamService youtubeService,
20-
IMusicQueueService queue,
20+
IGuildMusicService guildMusicService,
2121
IServiceProvider serviceProvider) : ApplicationCommandModule<ApplicationCommandContext>
2222
{
2323
[SubSlashCommand("blacklist", "Blacklist the currently playing song")]
2424
public async Task BlacklistAsync()
2525
{
26-
var song = queue.NowPlaying;
26+
if (Context.Guild is null)
27+
{
28+
await RespondAsync(InteractionCallback.Message("This command can only be used in a server."));
29+
return;
30+
}
31+
32+
var song = guildMusicService.GetNowPlaying(Context.Guild.Id);
2733
if (song is null)
2834
{
2935
await RespondAsync(InteractionCallback.Message(
@@ -61,7 +67,7 @@ await RespondAsync(InteractionCallback.Message(
6167

6268
// Skip the blacklisted track; the player disconnects on its own when the queue is empty.
6369
var eventDispatcher = scope.ServiceProvider.GetRequiredService<IEventDispatcher>();
64-
eventDispatcher.Dispatch(new EventType.Skip());
70+
eventDispatcher.Dispatch(new EventType.Skip(Context.Guild.Id));
6571
}
6672

6773
[SubSlashCommand("unblacklist", "Remove a song from the blacklist")]

src/Infrastructure/Commands/MusicActionCommands.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
namespace Infrastructure.Commands;
1212

13-
public class MusicActionCommands(IScopeExecutor executor, IMusicQueueService queue)
13+
public class MusicActionCommands(IScopeExecutor executor, IGuildMusicService guildMusicService)
1414
: ApplicationCommandModule<ApplicationCommandContext>
1515
{
1616
[SlashCommand("stop", "Stop playing and clear the queue")]
@@ -21,7 +21,7 @@ public async Task Stop()
2121
return;
2222
}
2323

24-
DispatchEvent(new EventType.Stop());
24+
DispatchEvent(new EventType.Stop(Context.Guild!.Id));
2525
var message =
2626
CommandUtils.CreateMessage<InteractionMessageProperties>("Stopping playback and clearing the queue.");
2727
await RespondAsync(InteractionCallback.Message(message));
@@ -35,7 +35,7 @@ public async Task Skip()
3535
return;
3636
}
3737

38-
DispatchEvent(new EventType.Skip());
38+
DispatchEvent(new EventType.Skip(Context.Guild!.Id));
3939
var message = CommandUtils.CreateMessage<InteractionMessageProperties>("Skipping the current track.");
4040
await RespondAsync(InteractionCallback.Message(message));
4141
}
@@ -48,7 +48,7 @@ public async Task Playlist()
4848
return;
4949
}
5050

51-
var requests = queue.GetAllRequests();
51+
var requests = guildMusicService.GetAllRequests(Context.Guild!.Id);
5252
if (requests.Length == 0)
5353
await RespondAsync(InteractionCallback.Message("No songs in queue."));
5454
else
@@ -87,14 +87,14 @@ public async Task Rewind()
8787
}
8888

8989
InteractionMessageProperties message;
90-
if(queue.NowPlaying is null)
90+
if (guildMusicService.GetNowPlaying(Context.Guild!.Id) is null)
9191
{
9292
message = CommandUtils.CreateMessage<InteractionMessageProperties>("No songs in queue.");
9393
await RespondAsync(InteractionCallback.Message(message));
9494
return;
9595
}
9696

97-
queue.Rewind();
97+
guildMusicService.Rewind(Context.Guild.Id);
9898
message = CommandUtils.CreateMessage<InteractionMessageProperties>("Rewinding the current track.");
9999
await RespondAsync(InteractionCallback.Message(message));
100100
}

src/Infrastructure/Interaction/NetCordInteraction.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace Infrastructure.Interaction;
1313

1414
public class NetCordInteraction(
1515
ILogger<NetCordInteraction> logger,
16-
IMusicQueueService queueService,
16+
IGuildMusicService guildMusicService,
1717
IBlacklistService blacklistService,
1818
YoutubeClient youtubeClient,
1919
[FromKeyedServices(nameof(YoutubeService))] IStreamService youtubeService) : ComponentInteractionModule<StringMenuInteractionContext>
@@ -53,7 +53,7 @@ public async Task<string> Play()
5353
Context = Context,
5454
Callbacks = async callbackMessage => await RespondAsyncCallback(callbackMessage),
5555
};
56-
queueService.Enqueue(playRequest);
56+
guildMusicService.Enqueue(Context.Guild!.Id, playRequest);
5757

5858
return message;
5959
}
@@ -91,7 +91,7 @@ public async Task<string> PlayPlaylist()
9191
VideoTitle = video.Title,
9292
VideoUrl = video.Url
9393
};
94-
queueService.Enqueue(playRequest);
94+
guildMusicService.Enqueue(Context.Guild!.Id, playRequest);
9595
added++;
9696
}
9797

0 commit comments

Comments
 (0)