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
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x

# Only the test project is built: it covers Domain/Application/Infrastructure.
# The full .slnx is not built here because the Worker frontend build requires Bun.
- name: Run tests
run: dotnet test src/Tests/Tests.csproj -c Release --logger "console;verbosity=normal"
114 changes: 73 additions & 41 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,87 +4,119 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

Discord Music Bot - A .NET 10 application that provides music playback functionality for Discord servers. Supports various audio sources, Spotify (for playlist fetching), and radio streams.
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.

## Build & Run Commands

```bash
# Build and run with Docker (recommended)
# Recommended: build and run everything (bot + Postgres) via Docker
docker-compose -f deployment/docker-compose.yml up --build

# Build .NET solution
# Build the whole solution (uses .slnx format)
dotnet build discord-project.slnx

# Run the Worker (main entry point)
# Run the Worker (composition root, hosts Discord bot + web API on :5000)
dotnet run --project src/Worker/Worker.csproj

# Run with specific configuration
# Release configuration
dotnet run --project src/Worker/Worker.csproj -c Release

# Frontend dev server (from src/UI/App)
bun run dev
# Run tests (unit tests always; integration tests need a local Docker daemon for Testcontainers)
dotnet test src/Tests/Tests.csproj

# Frontend production build
bun run build
# Unit tests only (no Docker required)
dotnet test src/Tests/Tests.csproj --filter "FullyQualifiedName~Tests.Unit"
```

Frontend (run from `src/UI/App/`, Bun is the package manager):

# Frontend development build
bun run build:dev
```bash
bun run dev # Vite dev server
bun run build # Type-check + production build (output: src/Worker/wwwroot/)
bun run build:dev # Same, but development mode
bun run lint # ESLint
bun run format # Prettier write
bun run format:check # Prettier check
```

Tests live in `src/Tests/` (xUnit + NSubstitute; integration tests use Testcontainers PostgreSQL against the real migrations). The test project references Domain/Application/Infrastructure but deliberately NOT Worker (whose build shells out to Bun for the frontend).

## Architecture

### Clean Architecture Layers

- **Domain** (`src/Domain/`) - Core entities (Song, User, PlayHistory, RadioSource), event interfaces (`IEventHandler<T>`, `IAsyncEventHandler<T>`), and common abstractions
- **Application** (`src/Application/`) - Business logic services, DTOs, configuration models, event dispatching system, and service interfaces
- **Infrastructure** (`src/Infrastructure/`) - Discord bot implementation using NetCord, audio processing with FFmpeg/NAudio, database access via EF Core, YouTube/SoundCloud integration
- **UI** (`src/UI/`) - Contains two sub-projects:
- `Api/` - ASP.NET Core REST API with JWT authentication
- `App/` - React TypeScript frontend (Vite build, Bun package manager)
- **Worker** (`src/Worker/`) - Main entry point that composes all layers, hosts the Discord bot and web API
- **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).
- **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.

### Key Patterns

**Event System**: Custom domain event dispatching via `IEventHandler<T>` and `IAsyncEventHandler<T>`. Handlers are auto-discovered from assemblies at startup via `AddEventing()`.
**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.

**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`.

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

**Service Registration**: Keyed services pattern for multiple implementations:
```csharp
services.AddKeyedScoped<IStreamService, YoutubeService>(nameof(YoutubeService));
services.AddKeyedScoped<IStreamService, SoundCloudService>(nameof(SoundCloudService));
services.AddKeyedScoped<IRandomService, JokeService>(nameof(JokeService));
services.AddKeyedScoped<IRandomService, QuoteService>(nameof(QuoteService));
```

**Discord Commands**: Organized in `src/Infrastructure/Commands/`:
- `MusicPlayCommands.cs` - Play/queue commands
- `MusicActionCommands.cs` - Pause, skip, volume, etc.
- `AdminCommands.cs` - Admin functionality
- `MiscCommands.cs` - Utility commands
**Discord commands** (`src/Infrastructure/Commands/`):
- `PlayCommand` (in `MusicPlayCommands.cs`) - `/play music` and radio subcommands
- `MusicActionCommands` - pause, skip, stop, volume
- `AdminCommands` - admin-only actions
- `MiscCommands` - help, joke, motivate

Commands and the `NetCordInteraction` component module are wired in `Worker.DependencyInjection.AddWebApplication`.

**Scoped work from singletons**: `IScopeExecutor` (`ScopeExecutor`) is used by singleton services (like commands calling into scoped `DiscordBotContext`) to open a DI scope on demand.

### Database

PostgreSQL with EF Core. Context: `Infrastructure/Data/DiscordBotContext.cs`. Migrations run automatically on startup.
PostgreSQL + EF Core 10. Context: `Infrastructure/Data/DiscordBotContext.cs`. Uses **compiled models** (`Infrastructure.CompiledModels.DiscordBotContextModel`) for startup performance; if you change the model, regenerate the compiled model in addition to adding a migration. Migrations live in `src/Infrastructure/Data/Migrations/` and are applied automatically at startup by `context.Database.MigrateAsync()` in `Program.cs`.

### Audio Pipeline

Audio flows through: YoutubeDLSharp/YoutubeExplode -> FFmpeg processing (`FfmpegProcessService`) -> NAudio conversion -> NetCord voice client
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.

### Native Dependencies (Linux/Docker)
### Native Dependencies (installed in the Docker image)

- FFmpeg
- libsodium
- libopus
- libdave
- yt-dlp
- libsodium, libopus
- libdave (fetched from the Discord libdave releases zip in the Dockerfile)
- yt-dlp (pulled from the yt-dlp `latest` release at image build time; `YT_DLP_CACHE_BUST` build arg forces a re-download)
- python3 (required by yt-dlp)

## Configuration

Environment variables (via docker-compose or appsettings.json):
- `Discord__Token` - Discord bot token
- `SpotifySettings__ClientId/ClientSecret` - Spotify API credentials
- `ConnectionStrings__DefaultConnection` - PostgreSQL connection string
- `JwtSettings__Secret/Issuer/Audience` - JWT configuration
- `WebsiteSettings__Url` - Base URL for the web UI
- `Cors__AllowedOrigins` - Allowed CORS origins
.NET config keys (colon separators become double-underscore in env vars, per `deployment/docker-compose.yml`):

- `Discord:Token`
- `SpotifySettings:ClientId` / `SpotifySettings:ClientSecret`
- `ConnectionStrings:DefaultConnection`
- `JwtSettings:Secret` / `Issuer` / `Audience` / `InternalPassword`
- `WebsiteSettings:Url`
- `Cors:AllowedOrigins` (JSON array in env var form: `[origin1,origin2]`)
- `Ffmpeg:Path` (optional, defaults to `/usr/bin/ffmpeg`)

For local Docker runs, populate `deployment/.env` (see `.github/workflows/release.yml` for the exact key list).

## Toolchain

- .NET SDK: `global.json` pins 9.0.3 with `rollForward: latestMajor`, so SDK 10+ satisfies it. All projects target `net10.0` via `src/Directory.Build.props` (nullable + implicit usings enabled).
- Central package management: all NuGet versions live in `src/Directory.Packages.props`.
- Frontend: React 19, Vite 8, TanStack Router + Query, Recharts, Sonner. Uses `babel-plugin-react-compiler` via the Vite React plugin.

## Deployment

## Frontend Stack
`.github/workflows/release.yml` deploys on push to `master` via a self-hosted Linux runner: tears down the running compose stack, writes `deployment/.env` from GitHub secrets, then rebuilds and restarts with `docker compose up -d`. The build passes `YT_DLP_CACHE_BUST=$(date +%Y%m%d)` so yt-dlp refreshes daily.

React 19 with TypeScript, TanStack Router, TanStack Query, Recharts. Uses Bun as the package manager and Vite 8 for bundling. Build output goes to `src/Worker/wwwroot/`.
`.github/workflows/ci.yml` runs `dotnet test src/Tests/Tests.csproj` on GitHub-hosted Ubuntu for pushes and PRs (Docker is preinstalled there, so the Testcontainers integration tests run too). It intentionally does not build the full `.slnx` because the Worker frontend build requires Bun.
1 change: 1 addition & 0 deletions discord-project.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<Project Path="src/Domain/Domain.csproj" />
<Project Path="src/Infrastructure/Infrastructure.csproj" />
<Project Path="src/Worker/Worker.csproj" />
<Project Path="src/Tests/Tests.csproj" />
<File Path="src/Directory.Build.props" />
<File Path="src/Directory.Packages.props" />
</Folder>
Expand Down
1 change: 1 addition & 0 deletions src/Application/Application.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>

<ItemGroup>
Expand Down
16 changes: 16 additions & 0 deletions src/Application/DTOs/TrackPlayResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace Application.DTOs;

public enum TrackPlayResult
{
/// <summary>The track played to the end (ffmpeg exit code 0).</summary>
Completed,

/// <summary>Playback failed (non-zero exit code or source resolution failure); candidate for retry.</summary>
Failed,

/// <summary>Playback was cancelled via the track cancellation token (skip or stop).</summary>
Skipped,

/// <summary>The requesting user is not in a voice channel; the track is dropped.</summary>
NotInVoiceChannel
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using System.Reflection;
using Application.Eventing;
using Domain.Eventing;
using Microsoft.Extensions.DependencyInjection;

namespace Worker;
namespace Application.Eventing;

public static class EventingServiceCollectionExtensions
{
Expand Down Expand Up @@ -74,4 +74,4 @@ private static bool IsEventHandlerType(Type type)
(i.GetGenericTypeDefinition() == typeof(IEventHandler<>) ||
i.GetGenericTypeDefinition() == typeof(IAsyncEventHandler<>)));
}
}
}
16 changes: 13 additions & 3 deletions src/Application/Interfaces/Services/IBlacklistService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,18 @@ namespace Application.Interfaces.Services;

public interface IBlacklistService
{
Task AddToBlacklistAsync(string sourceUrl);
Task RemoveFromBlacklistAsync(string title);
/// <summary>
/// Marks the song with the given source URL as blacklisted.
/// Returns false when no song with that URL exists.
/// </summary>
Task<bool> AddToBlacklistAsync(string sourceUrl);

/// <summary>
/// Removes the first song whose title contains the given text from the blacklist.
/// Returns false when no matching song exists.
/// </summary>
Task<bool> RemoveFromBlacklistAsync(string title);

Task<bool> IsBlacklistedAsync(string sourceUrl);
Task<List<Song>> GetBlacklistedSongsAsync();
}
}
18 changes: 18 additions & 0 deletions src/Application/Interfaces/Services/IMusicPlayerController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace Application.Interfaces.Services;

/// <summary>
/// Control surface for the music player background service.
/// </summary>
public interface IMusicPlayerController
{
/// <summary>
/// Cancels the currently playing track, if any. The player advances to the next queued track.
/// </summary>
void Skip();

/// <summary>
/// Clears the pending queue and cancels the currently playing track.
/// The player disconnects from the voice channel once idle.
/// </summary>
void Stop();
}
30 changes: 27 additions & 3 deletions src/Application/Interfaces/Services/IMusicQueueService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,34 @@ namespace Application.Interfaces.Services;
public interface IMusicQueueService
{
void Enqueue<T>(PlayRequest<T> request);
PlayRequest<T>? Peek<T>();
void DequeueAsync(CancellationToken cancellationToken);

/// <summary>
/// Waits until a request is available and removes it from the pending queue.
/// Intended to be consumed by a single background consumer.
/// </summary>
ValueTask<PlayRequest<T>> DequeueAsync<T>(CancellationToken cancellationToken);

/// <summary>
/// Number of pending requests (excludes the currently playing one).
/// </summary>
int Count { get; }

/// <summary>
/// The request currently being played, if any. Owned by the player background service.
/// </summary>
PlayRequest? NowPlaying { get; }

void SetNowPlaying(PlayRequest? request);

/// <summary>
/// Snapshot of the queue: the currently playing request first (if any), then pending ones.
/// </summary>
PlayRequest[] GetAllRequests();

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

void Clear();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ namespace Application.Interfaces.Services;

public interface INativePlaceMusicProcessorService
{
/// <summary>
/// Stops any previously running process and starts a new ffmpeg process decoding the given URL to PCM on stdout.
/// </summary>
Task<Process> CreateStreamAsync(string audioUrl, CancellationToken cancellationToken);
event Func<Task>? OnPlaySongCompleted;
event Func<Task>? OnProcessStart;
event Func<Task>? OnForbiddenUrlRequest;
}

/// <summary>
/// Gracefully terminates the current ffmpeg process (stdin "q", then kill after a grace period).
/// </summary>
Task StopCurrentProcessAsync();
}
17 changes: 13 additions & 4 deletions src/Application/Interfaces/Services/INetCordAudioPlayerService.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
using Application.DTOs;

namespace Application.Interfaces.Services;

public interface INetCordAudioPlayerService
{
event Func<Task>? DisconnectedVoiceClientEvent;
event Func<Task>? NotInVoiceChannelCallback;
Task Play(Action<Func<Task>> disconnectAsync);
}
/// <summary>
/// Plays a single request to completion. Joins the voice channel when not connected.
/// Returns when the track finishes, fails, or is cancelled.
/// </summary>
Task<TrackPlayResult> PlayTrackAsync(PlayRequest request, CancellationToken cancellationToken);

/// <summary>
/// Leaves the voice channel and releases the voice client.
/// </summary>
Task DisconnectAsync();
}
12 changes: 7 additions & 5 deletions src/Application/Services/HttpRequestService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,21 @@

namespace Application.Services;

public class HttpRequestService : IHttpRequestService
public class HttpRequestService(IHttpClientFactory httpClientFactory) : IHttpRequestService
{
public async Task<T> GetAsync<T>(string url, object? data = null, string? token = null)
{
using var httpClient = new HttpClient();
var httpClient = httpClientFactory.CreateClient();
if (!string.IsNullOrEmpty(token))
{
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}

if (data is not null)
{
var query = string.Join("&", data.GetType().GetProperties().Select(x => $"{x.Name}={x.GetValue(data)}"));
var query = string.Join("&", data.GetType().GetProperties()
.Select(x =>
$"{Uri.EscapeDataString(x.Name)}={Uri.EscapeDataString(x.GetValue(data)?.ToString() ?? string.Empty)}"));
url += $"?{query}";
}

Expand All @@ -34,7 +36,7 @@ public async Task<T> GetAsync<T>(string url, object? data = null, string? token
public async Task<T> PostAsync<T>(string url, object data,
PostRequestMediaType mediaType = PostRequestMediaType.Json)
{
using var httpClient = new HttpClient();
var httpClient = httpClientFactory.CreateClient();
HttpResponseMessage? response = null;
if (mediaType == PostRequestMediaType.Json)
{
Expand Down Expand Up @@ -68,4 +70,4 @@ private static Dictionary<string, string> ObjectToKeyValuePairs(object obj)

return keyValuePairs;
}
}
}
Loading
Loading