Skip to content

Commit 8f8a299

Browse files
authored
Merge pull request #104 from standleypg-dev/platform-health
Refactor services to support cancellation tokens and improve error ha…
2 parents 51dc48c + 9cfacbe commit 8f8a299

42 files changed

Lines changed: 1810 additions & 803 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
branches: [master]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Setup .NET
16+
uses: actions/setup-dotnet@v4
17+
with:
18+
dotnet-version: 10.0.x
19+
20+
# Only the test project is built: it covers Domain/Application/Infrastructure.
21+
# The full .slnx is not built here because the Worker frontend build requires Bun.
22+
- name: Run tests
23+
run: dotnet test src/Tests/Tests.csproj -c Release --logger "console;verbosity=normal"

CLAUDE.md

Lines changed: 73 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,87 +4,119 @@ 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 provides music playback functionality for Discord servers. Supports various audio sources, Spotify (for playlist fetching), and radio streams.
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.
88

99
## Build & Run Commands
1010

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

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

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

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

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

27-
# Frontend production build
28-
bun run build
27+
# Unit tests only (no Docker required)
28+
dotnet test src/Tests/Tests.csproj --filter "FullyQualifiedName~Tests.Unit"
29+
```
30+
31+
Frontend (run from `src/UI/App/`, Bun is the package manager):
2932

30-
# Frontend development build
31-
bun run build:dev
33+
```bash
34+
bun run dev # Vite dev server
35+
bun run build # Type-check + production build (output: src/Worker/wwwroot/)
36+
bun run build:dev # Same, but development mode
37+
bun run lint # ESLint
38+
bun run format # Prettier write
39+
bun run format:check # Prettier check
3240
```
3341

42+
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).
43+
3444
## Architecture
3545

3646
### Clean Architecture Layers
3747

38-
- **Domain** (`src/Domain/`) - Core entities (Song, User, PlayHistory, RadioSource), event interfaces (`IEventHandler<T>`, `IAsyncEventHandler<T>`), and common abstractions
39-
- **Application** (`src/Application/`) - Business logic services, DTOs, configuration models, event dispatching system, and service interfaces
40-
- **Infrastructure** (`src/Infrastructure/`) - Discord bot implementation using NetCord, audio processing with FFmpeg/NAudio, database access via EF Core, YouTube/SoundCloud integration
41-
- **UI** (`src/UI/`) - Contains two sub-projects:
42-
- `Api/` - ASP.NET Core REST API with JWT authentication
43-
- `App/` - React TypeScript frontend (Vite build, Bun package manager)
44-
- **Worker** (`src/Worker/`) - Main entry point that composes all layers, hosts the Discord bot and web API
48+
- **Domain** (`src/Domain/`) - Entities (`Song`, `User`, `PlayHistory`, `RadioSource`), event handler interfaces (`IEventHandler<T>`, `IAsyncEventHandler<T>`), and enums.
49+
- **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).
52+
- **UI** (`src/UI/`)
53+
- `Api/` - ASP.NET Core minimal-API endpoints (see `ControllerExtensions.cs`) with JWT bearer auth. Login checks against `JwtSettings:InternalPassword` (no user password hashing).
54+
- `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.
4556

4657
### Key Patterns
4758

48-
**Event System**: Custom domain event dispatching via `IEventHandler<T>` and `IAsyncEventHandler<T>`. Handlers are auto-discovered from assemblies at startup via `AddEventing()`.
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.
60+
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`.
62+
63+
**Keyed services**: Multiple implementations of `IStreamService` and `IRandomService` are registered by name and resolved with `[FromKeyedServices(nameof(...))]`:
4964

50-
**Service Registration**: Keyed services pattern for multiple implementations:
5165
```csharp
5266
services.AddKeyedScoped<IStreamService, YoutubeService>(nameof(YoutubeService));
5367
services.AddKeyedScoped<IStreamService, SoundCloudService>(nameof(SoundCloudService));
68+
services.AddKeyedScoped<IRandomService, JokeService>(nameof(JokeService));
69+
services.AddKeyedScoped<IRandomService, QuoteService>(nameof(QuoteService));
5470
```
5571

56-
**Discord Commands**: Organized in `src/Infrastructure/Commands/`:
57-
- `MusicPlayCommands.cs` - Play/queue commands
58-
- `MusicActionCommands.cs` - Pause, skip, volume, etc.
59-
- `AdminCommands.cs` - Admin functionality
60-
- `MiscCommands.cs` - Utility commands
72+
**Discord commands** (`src/Infrastructure/Commands/`):
73+
- `PlayCommand` (in `MusicPlayCommands.cs`) - `/play music` and radio subcommands
74+
- `MusicActionCommands` - pause, skip, stop, volume
75+
- `AdminCommands` - admin-only actions
76+
- `MiscCommands` - help, joke, motivate
77+
78+
Commands and the `NetCordInteraction` component module are wired in `Worker.DependencyInjection.AddWebApplication`.
79+
80+
**Scoped work from singletons**: `IScopeExecutor` (`ScopeExecutor`) is used by singleton services (like commands calling into scoped `DiscordBotContext`) to open a DI scope on demand.
6181

6282
### Database
6383

64-
PostgreSQL with EF Core. Context: `Infrastructure/Data/DiscordBotContext.cs`. Migrations run automatically on startup.
84+
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`.
6585

6686
### Audio Pipeline
6787

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

70-
### Native Dependencies (Linux/Docker)
90+
### Native Dependencies (installed in the Docker image)
7191

7292
- FFmpeg
73-
- libsodium
74-
- libopus
75-
- libdave
76-
- yt-dlp
93+
- libsodium, libopus
94+
- libdave (fetched from the Discord libdave releases zip in the Dockerfile)
95+
- yt-dlp (pulled from the yt-dlp `latest` release at image build time; `YT_DLP_CACHE_BUST` build arg forces a re-download)
96+
- python3 (required by yt-dlp)
7797

7898
## Configuration
7999

80-
Environment variables (via docker-compose or appsettings.json):
81-
- `Discord__Token` - Discord bot token
82-
- `SpotifySettings__ClientId/ClientSecret` - Spotify API credentials
83-
- `ConnectionStrings__DefaultConnection` - PostgreSQL connection string
84-
- `JwtSettings__Secret/Issuer/Audience` - JWT configuration
85-
- `WebsiteSettings__Url` - Base URL for the web UI
86-
- `Cors__AllowedOrigins` - Allowed CORS origins
100+
.NET config keys (colon separators become double-underscore in env vars, per `deployment/docker-compose.yml`):
101+
102+
- `Discord:Token`
103+
- `SpotifySettings:ClientId` / `SpotifySettings:ClientSecret`
104+
- `ConnectionStrings:DefaultConnection`
105+
- `JwtSettings:Secret` / `Issuer` / `Audience` / `InternalPassword`
106+
- `WebsiteSettings:Url`
107+
- `Cors:AllowedOrigins` (JSON array in env var form: `[origin1,origin2]`)
108+
- `Ffmpeg:Path` (optional, defaults to `/usr/bin/ffmpeg`)
109+
110+
For local Docker runs, populate `deployment/.env` (see `.github/workflows/release.yml` for the exact key list).
111+
112+
## Toolchain
113+
114+
- .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).
115+
- Central package management: all NuGet versions live in `src/Directory.Packages.props`.
116+
- Frontend: React 19, Vite 8, TanStack Router + Query, Recharts, Sonner. Uses `babel-plugin-react-compiler` via the Vite React plugin.
117+
118+
## Deployment
87119

88-
## Frontend Stack
120+
`.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.
89121

90-
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/`.
122+
`.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.

discord-project.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<Project Path="src/Domain/Domain.csproj" />
55
<Project Path="src/Infrastructure/Infrastructure.csproj" />
66
<Project Path="src/Worker/Worker.csproj" />
7+
<Project Path="src/Tests/Tests.csproj" />
78
<File Path="src/Directory.Build.props" />
89
<File Path="src/Directory.Packages.props" />
910
</Folder>

src/Application/Application.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
77
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
88
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
9+
<PackageReference Include="Microsoft.Extensions.Http" />
910
</ItemGroup>
1011

1112
<ItemGroup>
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace Application.DTOs;
2+
3+
public enum TrackPlayResult
4+
{
5+
/// <summary>The track played to the end (ffmpeg exit code 0).</summary>
6+
Completed,
7+
8+
/// <summary>Playback failed (non-zero exit code or source resolution failure); candidate for retry.</summary>
9+
Failed,
10+
11+
/// <summary>Playback was cancelled via the track cancellation token (skip or stop).</summary>
12+
Skipped,
13+
14+
/// <summary>The requesting user is not in a voice channel; the track is dropped.</summary>
15+
NotInVoiceChannel
16+
}

src/Worker/EventingServiceCollectionExtensions.cs renamed to src/Application/Eventing/EventingServiceCollectionExtensions.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
using System.Reflection;
2-
using Application.Eventing;
32
using Domain.Eventing;
3+
using Microsoft.Extensions.DependencyInjection;
44

5-
namespace Worker;
5+
namespace Application.Eventing;
66

77
public static class EventingServiceCollectionExtensions
88
{
@@ -74,4 +74,4 @@ private static bool IsEventHandlerType(Type type)
7474
(i.GetGenericTypeDefinition() == typeof(IEventHandler<>) ||
7575
i.GetGenericTypeDefinition() == typeof(IAsyncEventHandler<>)));
7676
}
77-
}
77+
}

src/Application/Interfaces/Services/IBlacklistService.cs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,18 @@ namespace Application.Interfaces.Services;
44

55
public interface IBlacklistService
66
{
7-
Task AddToBlacklistAsync(string sourceUrl);
8-
Task RemoveFromBlacklistAsync(string title);
7+
/// <summary>
8+
/// Marks the song with the given source URL as blacklisted.
9+
/// Returns false when no song with that URL exists.
10+
/// </summary>
11+
Task<bool> AddToBlacklistAsync(string sourceUrl);
12+
13+
/// <summary>
14+
/// Removes the first song whose title contains the given text from the blacklist.
15+
/// Returns false when no matching song exists.
16+
/// </summary>
17+
Task<bool> RemoveFromBlacklistAsync(string title);
18+
919
Task<bool> IsBlacklistedAsync(string sourceUrl);
1020
Task<List<Song>> GetBlacklistedSongsAsync();
11-
}
21+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
namespace Application.Interfaces.Services;
2+
3+
/// <summary>
4+
/// Control surface for the music player background service.
5+
/// </summary>
6+
public interface IMusicPlayerController
7+
{
8+
/// <summary>
9+
/// Cancels the currently playing track, if any. The player advances to the next queued track.
10+
/// </summary>
11+
void Skip();
12+
13+
/// <summary>
14+
/// Clears the pending queue and cancels the currently playing track.
15+
/// The player disconnects from the voice channel once idle.
16+
/// </summary>
17+
void Stop();
18+
}

src/Application/Interfaces/Services/IMusicQueueService.cs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,34 @@ namespace Application.Interfaces.Services;
55
public interface IMusicQueueService
66
{
77
void Enqueue<T>(PlayRequest<T> request);
8-
PlayRequest<T>? Peek<T>();
9-
void DequeueAsync(CancellationToken cancellationToken);
8+
9+
/// <summary>
10+
/// Waits until a request is available and removes it from the pending queue.
11+
/// Intended to be consumed by a single background consumer.
12+
/// </summary>
13+
ValueTask<PlayRequest<T>> DequeueAsync<T>(CancellationToken cancellationToken);
14+
15+
/// <summary>
16+
/// Number of pending requests (excludes the currently playing one).
17+
/// </summary>
1018
int Count { get; }
19+
20+
/// <summary>
21+
/// The request currently being played, if any. Owned by the player background service.
22+
/// </summary>
23+
PlayRequest? NowPlaying { get; }
24+
25+
void SetNowPlaying(PlayRequest? request);
26+
27+
/// <summary>
28+
/// Snapshot of the queue: the currently playing request first (if any), then pending ones.
29+
/// </summary>
1130
PlayRequest[] GetAllRequests();
31+
32+
/// <summary>
33+
/// Re-queues the currently playing request at the front so it plays again.
34+
/// </summary>
1235
void Rewind();
36+
1337
void Clear();
14-
}
38+
}

src/Application/Interfaces/Services/INativePlaceMusicProcessorService.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@ namespace Application.Interfaces.Services;
44

55
public interface INativePlaceMusicProcessorService
66
{
7+
/// <summary>
8+
/// Stops any previously running process and starts a new ffmpeg process decoding the given URL to PCM on stdout.
9+
/// </summary>
710
Task<Process> CreateStreamAsync(string audioUrl, CancellationToken cancellationToken);
8-
event Func<Task>? OnPlaySongCompleted;
9-
event Func<Task>? OnProcessStart;
10-
event Func<Task>? OnForbiddenUrlRequest;
11-
}
11+
12+
/// <summary>
13+
/// Gracefully terminates the current ffmpeg process (stdin "q", then kill after a grace period).
14+
/// </summary>
15+
Task StopCurrentProcessAsync();
16+
}

0 commit comments

Comments
 (0)