Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<T>`, `IAsyncEventHandler<T>`), 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<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.
**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.

**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(...))]`:

Expand All @@ -70,9 +70,10 @@ services.AddKeyedScoped<IRandomService, QuoteService>(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`.
Expand All @@ -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)

Expand Down
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
Expand All @@ -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)
- [Vite](https://vite.dev) + [Bun](https://bun.sh)
46 changes: 46 additions & 0 deletions src/Application/Interfaces/Services/IGuildMusicService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Application.DTOs;

namespace Application.Interfaces.Services;

/// <summary>
/// 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.
/// </summary>
public interface IGuildMusicService
{
void Enqueue<T>(ulong guildId, PlayRequest<T> request);

/// <summary>
/// The request currently being played in the guild, or null when the guild has no
/// player or nothing is playing.
/// </summary>
PlayRequest? GetNowPlaying(ulong guildId);

/// <summary>
/// Snapshot of the guild's queue: the currently playing request first (if any),
/// then pending ones. Empty when the guild has no player.
/// </summary>
PlayRequest[] GetAllRequests(ulong guildId);

/// <summary>
/// Number of pending requests in the guild (excludes the currently playing one).
/// </summary>
int GetQueueCount(ulong guildId);

/// <summary>
/// Re-queues the guild's currently playing request at the front so it plays again.
/// </summary>
void Rewind(ulong guildId);

/// <summary>
/// Cancels the guild's current track. No-op when the guild has no player.
/// </summary>
void Skip(ulong guildId);

/// <summary>
/// Clears the guild's queue and cancels its current track. No-op when the guild
/// has no player.
/// </summary>
void Stop(ulong guildId);
}
18 changes: 0 additions & 18 deletions src/Application/Interfaces/Services/IMusicPlayerController.cs

This file was deleted.

4 changes: 2 additions & 2 deletions src/Domain/Common/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions src/Infrastructure/Commands/AdminCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,19 @@ namespace Infrastructure.Commands;
public class AdminCommands(
[FromKeyedServices(nameof(YoutubeService))]
IStreamService youtubeService,
IMusicQueueService queue,
IGuildMusicService guildMusicService,
IServiceProvider serviceProvider) : ApplicationCommandModule<ApplicationCommandContext>
{
[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(
Expand Down Expand Up @@ -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<IEventDispatcher>();
eventDispatcher.Dispatch(new EventType.Skip());
eventDispatcher.Dispatch(new EventType.Skip(Context.Guild.Id));
}

[SubSlashCommand("unblacklist", "Remove a song from the blacklist")]
Expand Down
12 changes: 6 additions & 6 deletions src/Infrastructure/Commands/MusicActionCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

namespace Infrastructure.Commands;

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

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

DispatchEvent(new EventType.Skip());
DispatchEvent(new EventType.Skip(Context.Guild!.Id));
var message = CommandUtils.CreateMessage<InteractionMessageProperties>("Skipping the current track.");
await RespondAsync(InteractionCallback.Message(message));
}
Expand All @@ -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
Expand Down Expand Up @@ -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<InteractionMessageProperties>("No songs in queue.");
await RespondAsync(InteractionCallback.Message(message));
return;
}

queue.Rewind();
guildMusicService.Rewind(Context.Guild.Id);
message = CommandUtils.CreateMessage<InteractionMessageProperties>("Rewinding the current track.");
await RespondAsync(InteractionCallback.Message(message));
}
Expand Down
6 changes: 3 additions & 3 deletions src/Infrastructure/Interaction/NetCordInteraction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace Infrastructure.Interaction;

public class NetCordInteraction(
ILogger<NetCordInteraction> logger,
IMusicQueueService queueService,
IGuildMusicService guildMusicService,
IBlacklistService blacklistService,
YoutubeClient youtubeClient,
[FromKeyedServices(nameof(YoutubeService))] IStreamService youtubeService) : ComponentInteractionModule<StringMenuInteractionContext>
Expand Down Expand Up @@ -53,7 +53,7 @@ public async Task<string> Play()
Context = Context,
Callbacks = async callbackMessage => await RespondAsyncCallback(callbackMessage),
};
queueService.Enqueue(playRequest);
guildMusicService.Enqueue(Context.Guild!.Id, playRequest);

return message;
}
Expand Down Expand Up @@ -91,7 +91,7 @@ public async Task<string> PlayPlaylist()
VideoTitle = video.Title,
VideoUrl = video.Url
};
queueService.Enqueue(playRequest);
guildMusicService.Enqueue(Context.Guild!.Id, playRequest);
added++;
}

Expand Down
Loading
Loading