diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..86dc6e0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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" diff --git a/CLAUDE.md b/CLAUDE.md index e939e7c..a21c91c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`, `IAsyncEventHandler`), 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`, `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). +- **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` and `IAsyncEventHandler`. 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` / `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. + +**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(nameof(YoutubeService)); services.AddKeyedScoped(nameof(SoundCloudService)); +services.AddKeyedScoped(nameof(JokeService)); +services.AddKeyedScoped(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. diff --git a/discord-project.slnx b/discord-project.slnx index 9dca942..510e5a8 100644 --- a/discord-project.slnx +++ b/discord-project.slnx @@ -4,6 +4,7 @@ + diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index 7cd37e9..39d5226 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Application/DTOs/TrackPlayResult.cs b/src/Application/DTOs/TrackPlayResult.cs new file mode 100644 index 0000000..192a481 --- /dev/null +++ b/src/Application/DTOs/TrackPlayResult.cs @@ -0,0 +1,16 @@ +namespace Application.DTOs; + +public enum TrackPlayResult +{ + /// The track played to the end (ffmpeg exit code 0). + Completed, + + /// Playback failed (non-zero exit code or source resolution failure); candidate for retry. + Failed, + + /// Playback was cancelled via the track cancellation token (skip or stop). + Skipped, + + /// The requesting user is not in a voice channel; the track is dropped. + NotInVoiceChannel +} diff --git a/src/Worker/EventingServiceCollectionExtensions.cs b/src/Application/Eventing/EventingServiceCollectionExtensions.cs similarity index 96% rename from src/Worker/EventingServiceCollectionExtensions.cs rename to src/Application/Eventing/EventingServiceCollectionExtensions.cs index 108b865..d0689cf 100644 --- a/src/Worker/EventingServiceCollectionExtensions.cs +++ b/src/Application/Eventing/EventingServiceCollectionExtensions.cs @@ -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 { @@ -74,4 +74,4 @@ private static bool IsEventHandlerType(Type type) (i.GetGenericTypeDefinition() == typeof(IEventHandler<>) || i.GetGenericTypeDefinition() == typeof(IAsyncEventHandler<>))); } -} \ No newline at end of file +} diff --git a/src/Application/Interfaces/Services/IBlacklistService.cs b/src/Application/Interfaces/Services/IBlacklistService.cs index 21b43f3..67f21eb 100644 --- a/src/Application/Interfaces/Services/IBlacklistService.cs +++ b/src/Application/Interfaces/Services/IBlacklistService.cs @@ -4,8 +4,18 @@ namespace Application.Interfaces.Services; public interface IBlacklistService { - Task AddToBlacklistAsync(string sourceUrl); - Task RemoveFromBlacklistAsync(string title); + /// + /// Marks the song with the given source URL as blacklisted. + /// Returns false when no song with that URL exists. + /// + Task AddToBlacklistAsync(string sourceUrl); + + /// + /// Removes the first song whose title contains the given text from the blacklist. + /// Returns false when no matching song exists. + /// + Task RemoveFromBlacklistAsync(string title); + Task IsBlacklistedAsync(string sourceUrl); Task> GetBlacklistedSongsAsync(); -} \ No newline at end of file +} diff --git a/src/Application/Interfaces/Services/IMusicPlayerController.cs b/src/Application/Interfaces/Services/IMusicPlayerController.cs new file mode 100644 index 0000000..3079409 --- /dev/null +++ b/src/Application/Interfaces/Services/IMusicPlayerController.cs @@ -0,0 +1,18 @@ +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/Application/Interfaces/Services/IMusicQueueService.cs b/src/Application/Interfaces/Services/IMusicQueueService.cs index 10cbf82..82a8004 100644 --- a/src/Application/Interfaces/Services/IMusicQueueService.cs +++ b/src/Application/Interfaces/Services/IMusicQueueService.cs @@ -5,10 +5,34 @@ namespace Application.Interfaces.Services; public interface IMusicQueueService { void Enqueue(PlayRequest request); - PlayRequest? Peek(); - void DequeueAsync(CancellationToken cancellationToken); + + /// + /// Waits until a request is available and removes it from the pending queue. + /// Intended to be consumed by a single background consumer. + /// + ValueTask> DequeueAsync(CancellationToken cancellationToken); + + /// + /// Number of pending requests (excludes the currently playing one). + /// int Count { get; } + + /// + /// The request currently being played, if any. Owned by the player background service. + /// + PlayRequest? NowPlaying { get; } + + void SetNowPlaying(PlayRequest? request); + + /// + /// Snapshot of the queue: the currently playing request first (if any), then pending ones. + /// PlayRequest[] GetAllRequests(); + + /// + /// Re-queues the currently playing request at the front so it plays again. + /// void Rewind(); + void Clear(); -} \ No newline at end of file +} diff --git a/src/Application/Interfaces/Services/INativePlaceMusicProcessorService.cs b/src/Application/Interfaces/Services/INativePlaceMusicProcessorService.cs index bb6fd28..3b096cb 100644 --- a/src/Application/Interfaces/Services/INativePlaceMusicProcessorService.cs +++ b/src/Application/Interfaces/Services/INativePlaceMusicProcessorService.cs @@ -4,8 +4,13 @@ namespace Application.Interfaces.Services; public interface INativePlaceMusicProcessorService { + /// + /// Stops any previously running process and starts a new ffmpeg process decoding the given URL to PCM on stdout. + /// Task CreateStreamAsync(string audioUrl, CancellationToken cancellationToken); - event Func? OnPlaySongCompleted; - event Func? OnProcessStart; - event Func? OnForbiddenUrlRequest; -} \ No newline at end of file + + /// + /// Gracefully terminates the current ffmpeg process (stdin "q", then kill after a grace period). + /// + Task StopCurrentProcessAsync(); +} diff --git a/src/Application/Interfaces/Services/INetCordAudioPlayerService.cs b/src/Application/Interfaces/Services/INetCordAudioPlayerService.cs index 182fa51..a950b8a 100644 --- a/src/Application/Interfaces/Services/INetCordAudioPlayerService.cs +++ b/src/Application/Interfaces/Services/INetCordAudioPlayerService.cs @@ -1,8 +1,17 @@ +using Application.DTOs; + namespace Application.Interfaces.Services; public interface INetCordAudioPlayerService { - event Func? DisconnectedVoiceClientEvent; - event Func? NotInVoiceChannelCallback; - Task Play(Action> disconnectAsync); -} \ No newline at end of file + /// + /// Plays a single request to completion. Joins the voice channel when not connected. + /// Returns when the track finishes, fails, or is cancelled. + /// + Task PlayTrackAsync(PlayRequest request, CancellationToken cancellationToken); + + /// + /// Leaves the voice channel and releases the voice client. + /// + Task DisconnectAsync(); +} diff --git a/src/Application/Services/HttpRequestService.cs b/src/Application/Services/HttpRequestService.cs index 5be658d..925f442 100644 --- a/src/Application/Services/HttpRequestService.cs +++ b/src/Application/Services/HttpRequestService.cs @@ -7,11 +7,11 @@ namespace Application.Services; -public class HttpRequestService : IHttpRequestService +public class HttpRequestService(IHttpClientFactory httpClientFactory) : IHttpRequestService { public async Task GetAsync(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); @@ -19,7 +19,9 @@ public async Task GetAsync(string url, object? data = null, string? 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}"; } @@ -34,7 +36,7 @@ public async Task GetAsync(string url, object? data = null, string? token public async Task PostAsync(string url, object data, PostRequestMediaType mediaType = PostRequestMediaType.Json) { - using var httpClient = new HttpClient(); + var httpClient = httpClientFactory.CreateClient(); HttpResponseMessage? response = null; if (mediaType == PostRequestMediaType.Json) { @@ -68,4 +70,4 @@ private static Dictionary ObjectToKeyValuePairs(object obj) return keyValuePairs; } -} \ No newline at end of file +} diff --git a/src/Application/Store/GlobalStore.cs b/src/Application/Store/GlobalStore.cs index c79f3dd..2729318 100644 --- a/src/Application/Store/GlobalStore.cs +++ b/src/Application/Store/GlobalStore.cs @@ -10,12 +10,10 @@ namespace Application.Store; public class GlobalStore { private readonly ConcurrentDictionary _store = new(); - private readonly ReaderWriterLockSlim _lock = new(); /// /// Set the value of the item in the store of type /// If the item is already set, it will be overwritten - /// Use this only for the first time setting the value /// /// /// @@ -24,15 +22,7 @@ public void Set(T item) { ArgumentNullException.ThrowIfNull(item, nameof(item)); - EnterWriteLock(); - try - { - _store[typeof(T)] = item; - } - finally - { - ExitWriteLock(); - } + _store[typeof(T)] = item; } /// @@ -42,18 +32,7 @@ public void Set(T item) /// public T? Get() { - EnterReadLock(); - try - { - if (_store.TryGetValue(typeof(T), out var value)) - return (T)value; - else - return default; - } - finally - { - ExitReadLock(); - } + return _store.TryGetValue(typeof(T), out var value) ? (T)value : default; } /// @@ -64,24 +43,14 @@ public void Set(T item) /// public bool TryGet([NotNullWhen(true)] out T? item) { - EnterReadLock(); - try + if (_store.TryGetValue(typeof(T), out var value)) { - if (_store.TryGetValue(typeof(T), out var value)) - { - item = (T)value; - return true; - } - else - { - item = default; - return false; - } - } - finally - { - ExitReadLock(); + item = (T)value; + return true; } + + item = default; + return false; } /// @@ -92,19 +61,6 @@ public bool TryGet([NotNullWhen(true)] out T? item) /// public void Clear() { - EnterWriteLock(); - try - { - _store.TryRemove(typeof(T), out _); - } - finally - { - ExitWriteLock(); - } + _store.TryRemove(typeof(T), out _); } - - private void EnterWriteLock() => _lock.EnterWriteLock(); - private void ExitWriteLock() => _lock.ExitWriteLock(); - private void EnterReadLock() => _lock.EnterReadLock(); - private void ExitReadLock() => _lock.ExitReadLock(); -} \ No newline at end of file +} diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 8a98a63..eb7f834 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -8,6 +8,8 @@ + + @@ -24,5 +26,10 @@ + + + + + diff --git a/src/Domain/Common/PlayerState.cs b/src/Domain/Common/PlayerState.cs deleted file mode 100644 index 0a35f0c..0000000 --- a/src/Domain/Common/PlayerState.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Domain.Common; - -public class PlayerState -{ - /// - /// Cancellation token source for stop event. - /// - public CancellationTokenSource StopCts { get; set; } = null!; - /// - /// Cancellation token source for skip event. - /// - public CancellationTokenSource SkipCts { get; set; } = null!; - - public Func? DisconnectAsyncCallback { get; set; } - public PlayerAction CurrentAction { get; set; } = PlayerAction.Stop; - - public TVoiceClient? CurrentVoiceClient { get; set; } -} - -public enum PlayerAction -{ - Play, - Stop, - Skip -} \ No newline at end of file diff --git a/src/Infrastructure/Commands/AdminCommands.cs b/src/Infrastructure/Commands/AdminCommands.cs index 82e79e0..3444f2e 100644 --- a/src/Infrastructure/Commands/AdminCommands.cs +++ b/src/Infrastructure/Commands/AdminCommands.cs @@ -23,7 +23,7 @@ public class AdminCommands( [SubSlashCommand("blacklist", "Blacklist the currently playing song")] public async Task BlacklistAsync() { - var song = queue.Peek(); + var song = queue.NowPlaying; if (song is null) { await RespondAsync(InteractionCallback.Message( @@ -31,32 +31,37 @@ await RespondAsync(InteractionCallback.Message( return; } - var url = (song.ContextAsObject as StringMenuInteractionContext)?.SelectedValues[0]; + var url = song.VideoUrl ?? (song.ContextAsObject as StringMenuInteractionContext)?.SelectedValues[0]; if (url is null) { await RespondAsync(InteractionCallback.Message("No song was selected to blacklist.")); return; } - var title = await youtubeService.GetAudioStreamUrlAsync(url, CancellationToken.None); - await RespondAsync(InteractionCallback.Message($"The song with title '{title}' has been blacklisted.")); + if (Guid.TryParse(url, out _)) + { + await RespondAsync(InteractionCallback.Message("Radio stations cannot be blacklisted.")); + return; + } + + var title = await youtubeService.GetVideoTitleAsync(url, CancellationToken.None); using var scope = serviceProvider.CreateScope(); var blacklistService = scope.ServiceProvider.GetRequiredService(); - await blacklistService.AddToBlacklistAsync(url); - - // if the the queue has only one song, stop it - var eventDispatcher = scope.ServiceProvider.GetRequiredService(); - - if (queue.Count == 1) + var added = await blacklistService.AddToBlacklistAsync(url); + if (!added) { - eventDispatcher.Dispatch(new EventType.Stop()); - } - else - { - eventDispatcher.Dispatch(new EventType.Skip()); + await RespondAsync(InteractionCallback.Message( + $"The song with title '{title}' was not found in the library and could not be blacklisted.")); + return; } + + await RespondAsync(InteractionCallback.Message($"The song with title '{title}' has been blacklisted.")); + + // 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()); } [SubSlashCommand("unblacklist", "Remove a song from the blacklist")] @@ -65,11 +70,12 @@ public async Task UnblacklistAsync([CommandParameter(Remainder = true, Name = "s using var scope = serviceProvider.CreateScope(); var blacklistService = scope.ServiceProvider.GetRequiredService(); - await blacklistService.RemoveFromBlacklistAsync(title); + var removed = await blacklistService.RemoveFromBlacklistAsync(title); var message = - CommandUtils.CreateMessage( - $"The song with title '{title}' has been removed from the blacklist."); + CommandUtils.CreateMessage(removed + ? $"The song with title '{title}' has been removed from the blacklist." + : $"No blacklisted song matching '{title}' was found."); await RespondAsync(InteractionCallback.Message(message)); } @@ -92,4 +98,4 @@ public async Task ListBlacklistedSongsAsync() await RespondAsync(InteractionCallback.Message(message)); } -} \ No newline at end of file +} diff --git a/src/Infrastructure/Commands/CommandUtils.cs b/src/Infrastructure/Commands/CommandUtils.cs index 2063c65..4d617b9 100644 --- a/src/Infrastructure/Commands/CommandUtils.cs +++ b/src/Infrastructure/Commands/CommandUtils.cs @@ -35,7 +35,15 @@ internal static IEnumerable CreateComponent(T so internal static async Task NotInVoiceChannel(ApplicationCommandContext context, Func, Task> respondAsync) { - if (!context.Guild!.VoiceStates.TryGetValue(context.User.Id, out _)) + if (context.Guild is null) + { + var notInGuildMessage = + CreateMessage("This command can only be used in a server."); + await respondAsync(InteractionCallback.Message(notInGuildMessage)); + return true; + } + + if (!context.Guild.VoiceStates.TryGetValue(context.User.Id, out _)) { var notInVoiceChannelMessage = CreateMessage("You must be in a voice channel to use this command."); diff --git a/src/Infrastructure/Commands/MusicActionCommands.cs b/src/Infrastructure/Commands/MusicActionCommands.cs index 1f8dd8c..6d2d0a3 100644 --- a/src/Infrastructure/Commands/MusicActionCommands.cs +++ b/src/Infrastructure/Commands/MusicActionCommands.cs @@ -48,16 +48,17 @@ public async Task Playlist() return; } - if (queue.Count == 0) + var requests = queue.GetAllRequests(); + if (requests.Length == 0) await RespondAsync(InteractionCallback.Message("No songs in queue.")); else { await executor.ExecuteAsync(async serviceProvider => { var youtubeService = serviceProvider.GetRequiredKeyedService(nameof(YoutubeService)); - var songs = queue.GetAllRequests().Select(async r => + var songs = requests.Select(async r => { - var title = await youtubeService.GetVideoTitleAsync( + var title = r.VideoTitle ?? await youtubeService.GetVideoTitleAsync( r.VideoUrl ?? (r.ContextAsObject as StringMenuInteractionContext)?.SelectedValues[0]!, CancellationToken.None); return title; @@ -86,13 +87,13 @@ public async Task Rewind() } InteractionMessageProperties message; - if(queue.Count == 0) + if(queue.NowPlaying is null) { message = CommandUtils.CreateMessage("No songs in queue."); await RespondAsync(InteractionCallback.Message(message)); return; } - + queue.Rewind(); message = CommandUtils.CreateMessage("Rewinding the current track."); await RespondAsync(InteractionCallback.Message(message)); @@ -112,8 +113,7 @@ private void DispatchEvent(TEvent @event) where TEvent : IEvent { executor.Execute(serviceProvider => { - using var scope = serviceProvider.CreateScope(); - var eventDispatcher = scope.ServiceProvider.GetRequiredService(); + var eventDispatcher = serviceProvider.GetRequiredService(); eventDispatcher.Dispatch(@event); }); diff --git a/src/Infrastructure/Commands/MusicPlayCommands.cs b/src/Infrastructure/Commands/MusicPlayCommands.cs index f95c9e6..3c151e9 100644 --- a/src/Infrastructure/Commands/MusicPlayCommands.cs +++ b/src/Infrastructure/Commands/MusicPlayCommands.cs @@ -22,29 +22,20 @@ public async Task MusicPlayer([CommandParameter(Remainder = true, Name = "song t await executor.ExecuteAsync(async serviceProvider => { - var blacklistService = serviceProvider.GetRequiredService(); - if (await blacklistService.IsBlacklistedAsync(command)) - { - var blacklistMessage = - CommandUtils.CreateMessage( - "The requested song is blacklisted and cannot be played."); - await RespondAsync(InteractionCallback.Message(blacklistMessage)); - } - else - { - var youtubeClient = serviceProvider.GetRequiredService(); - var message = CommandUtils.CreateMessage("Select a track to play:"); + // Blacklist enforcement happens at selection time (NetCordInteraction.Play), + // where the actual video URL is known. + var youtubeClient = serviceProvider.GetRequiredService(); + var message = CommandUtils.CreateMessage("Select a track to play:"); - var source = - await youtubeClient.Search.GetVideosAsync(command) - .CollectAsync(5); + var source = + await youtubeClient.Search.GetVideosAsync(command) + .CollectAsync(5); - message.Components = - CommandUtils.CreateComponent(source.Select(s => - new CommandUtils.ComponentModel(s.Title, s.Url, s.Author.ChannelTitle))); + message.Components = + CommandUtils.CreateComponent(source.Select(s => + new CommandUtils.ComponentModel(s.Title, s.Url, s.Author.ChannelTitle))); - await RespondAsync(InteractionCallback.Message(message)); - } + await RespondAsync(InteractionCallback.Message(message)); }); } diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index a07ad4f..760bf53 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -11,6 +11,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/Infrastructure/Interaction/NetCordInteraction.cs b/src/Infrastructure/Interaction/NetCordInteraction.cs index 74d2aa0..d89ad2f 100644 --- a/src/Infrastructure/Interaction/NetCordInteraction.cs +++ b/src/Infrastructure/Interaction/NetCordInteraction.cs @@ -1,7 +1,6 @@ using Application.DTOs; using Application.Interfaces.Services; using Domain.Common; -using Domain.Eventing; using Infrastructure.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -14,8 +13,8 @@ namespace Infrastructure.Interaction; public class NetCordInteraction( ILogger logger, - IEventDispatcher eventDispatcher, IMusicQueueService queueService, + IBlacklistService blacklistService, YoutubeClient youtubeClient, [FromKeyedServices(nameof(YoutubeService))] IStreamService youtubeService) : ComponentInteractionModule { @@ -27,33 +26,38 @@ public async Task Play() { return error; } - + logger.LogInformation("Play command invoked by user {UserId} in guild {GuildId}", Context.User.Id, Context.Guild?.Id); - var playRequest = new PlayRequest - { - Context = Context, - Callbacks = async message => await RespondAsyncCallback(message), - }; - queueService.Enqueue(playRequest); - eventDispatcher.Dispatch(new EventType.Play()); var selectedValue = Context.SelectedValues[0]; string message; if (!Guid.TryParse(selectedValue, out _)) { - var title = await youtubeService.GetVideoTitleAsync(Context.SelectedValues[0], CancellationToken.None); + if (await blacklistService.IsBlacklistedAsync(selectedValue)) + { + return "The requested song is blacklisted and cannot be played."; + } + + var title = await youtubeService.GetVideoTitleAsync(selectedValue, CancellationToken.None); message = $"Added {title} to the queue!"; } else { - message = $"Added radio source to the queue!"; + message = "Added radio source to the queue!"; } - + + var playRequest = new PlayRequest + { + Context = Context, + Callbacks = async callbackMessage => await RespondAsyncCallback(callbackMessage), + }; + queueService.Enqueue(playRequest); + return message; } - + [ComponentInteraction(Constants.CustomIds.PlayListPlay)] public async Task PlayPlaylist() { @@ -62,14 +66,24 @@ public async Task PlayPlaylist() { return error; } - + logger.LogInformation("Play command invoked by user {UserId} in guild {GuildId}", Context.User.Id, Context.Guild?.Id); var videos = await youtubeClient.Playlists.GetVideosAsync(Context.SelectedValues[0]); - + + var blacklistedUrls = (await blacklistService.GetBlacklistedSongsAsync()) + .Select(s => s.SourceUrl) + .ToHashSet(); + + var added = 0; foreach (var video in videos) { + if (blacklistedUrls.Contains(video.Url)) + { + continue; + } + var playRequest = new PlayRequest { Context = Context, @@ -78,44 +92,50 @@ public async Task PlayPlaylist() VideoUrl = video.Url }; queueService.Enqueue(playRequest); + added++; } - - eventDispatcher.Dispatch(new EventType.Play()); - - return "Playlist Added to the queue!"; + + return added > 0 + ? "Playlist Added to the queue!" + : "All songs in the playlist are blacklisted; nothing was added."; } - + private string? EvaluateChecking() { + if (Context.Guild is null) + { + return "This command can only be used in a server."; + } + if (!CheckMessageExpiration()) { return "This interaction has expired. Please use the play command again."; } - + if (!NotInVoiceChannel()) { return "You must be in a voice channel to use this command."; } - + if (!NotDeafened()) { - return "You must be deafened to use this command."; + return "You must not be deafened to use this command."; } - + return null; } - + private bool NotInVoiceChannel() { return Context.Guild!.VoiceStates.TryGetValue(Context.User.Id, out _); } - + private bool NotDeafened() { var voiceState = Context.Guild!.VoiceStates[Context.User.Id]; return !(voiceState.IsDeafened || voiceState.IsSelfDeafened); } - + private bool CheckMessageExpiration() { var messageCreationTime = Context.Message.CreatedAt; @@ -125,4 +145,4 @@ private bool CheckMessageExpiration() } private Task RespondAsyncCallback(string message) => RespondAsync(InteractionCallback.Message(message))!; -} \ No newline at end of file +} diff --git a/src/Infrastructure/Services/AudioPlayerService.cs b/src/Infrastructure/Services/AudioPlayerService.cs index 95ebe6a..48f447b 100644 --- a/src/Infrastructure/Services/AudioPlayerService.cs +++ b/src/Infrastructure/Services/AudioPlayerService.cs @@ -1,7 +1,5 @@ using Application.DTOs; using Application.Interfaces.Services; -using Domain.Common; -using Domain.Eventing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using NetCord.Gateway; @@ -15,260 +13,162 @@ namespace Infrastructure.Services; public class AudioPlayerService( INativePlaceMusicProcessorService ffmpegProcessService, IServiceProvider serviceProvider, - ILogger logger, - PlayerState playerState, - IMusicQueueService queue) : INetCordAudioPlayerService + ILogger logger) : INetCordAudioPlayerService { - public event Func? DisconnectedVoiceClientEvent; - public event Func? NotInVoiceChannelCallback; - private Action> OnDisconnectAsync { get; set; } = _ => { }; + private VoiceClient? _voiceClient; + private GatewayClient? _gatewayClient; + private ulong? _guildId; - private readonly int _maxRetryCount = 3; - private PlayRequest? _currentTrack; - private readonly SemaphoreSlim _semaphore = new(1, 1); - - public async Task Play(Action> onDisconnectAsync) + public async Task PlayTrackAsync(PlayRequest request, CancellationToken cancellationToken) { - OnDisconnectAsync = onDisconnectAsync; + if (request is not PlayRequest track) + { + throw new ArgumentException( + $"Invalid request type. Expected {typeof(PlayRequest)}", + nameof(request)); + } - await HandleMusicPlayingAsync(); - } + var context = track.Context; + var guild = context.Guild; + if (guild is null || !guild.VoiceStates.TryGetValue(context.User.Id, out var voiceState)) + { + return TrackPlayResult.NotInVoiceChannel; + } - private async Task HandleMusicPlayingAsync() - { - logger.LogInformation("Entering semaphore to handle music playing"); - await _semaphore.WaitAsync(); try { - var ct = queue.Peek(); - - if (ct is null) - { - logger.LogError("Current track is null"); - return; - } - - _currentTrack = ct; - - var guild = _currentTrack.Context.Guild!; - // Get the user voice state - if (!guild.VoiceStates.TryGetValue(_currentTrack.Context.User.Id, out var voiceState)) - { - await (NotInVoiceChannelCallback?.Invoke() ?? Task.CompletedTask); - return; - } - - var client = _currentTrack.Context.Client; - - if (playerState.CurrentAction == PlayerAction.Stop) + if (_voiceClient is null) { - await StartVoiceClientAsync(); + await ConnectAsync(context.Client, guild.Id, voiceState.ChannelId.GetValueOrDefault(), + cancellationToken); } - if (playerState.CurrentVoiceClient is null) - { - logger.LogError("Voice client is null"); - return; - } + using var scope = serviceProvider.CreateScope(); + var streamService = scope.ServiceProvider.GetRequiredKeyedService(nameof(YoutubeService)); + var radioSourceService = scope.ServiceProvider.GetRequiredService(); + var statisticsService = scope.ServiceProvider.GetRequiredService(); - await HandleVoiceStream(); - - async Task HandleVoiceStream() - { - var outStream = playerState.CurrentVoiceClient.CreateVoiceStream(); + var selectedValue = track.VideoUrl ?? context.SelectedValues[0]; + var (sourceUrl, song) = await ResolveSourceAsync(selectedValue, track, context.User.Id, + streamService, radioSourceService, cancellationToken); - OpusEncodeStream stream = new(outStream, PcmFormat.Short, VoiceChannels.Stereo, OpusApplication.Audio); - - using var scope = serviceProvider.CreateScope(); - var youTubeService = - scope.ServiceProvider.GetRequiredKeyedService(nameof(YoutubeService)); - var radioSourceService = scope.ServiceProvider - .GetRequiredService(); - - var selectedValue = _currentTrack.VideoUrl ?? _currentTrack.Context.SelectedValues[0]; - var sourceUrl = await GetSourceUrl(selectedValue, radioSourceService, youTubeService, _currentTrack.Context.User.Id, _currentTrack.Context.User.Username, _currentTrack.Context.User.GlobalName); - - await StartFfmpegStream(sourceUrl, stream); - } - - async Task StartVoiceClientAsync() + var process = await ffmpegProcessService.CreateStreamAsync(sourceUrl, cancellationToken); + try { - playerState.CurrentVoiceClient = await client.JoinVoiceChannelAsync( - guild.Id, - voiceState.ChannelId.GetValueOrDefault(), - new VoiceClientConfiguration - { - Logger = new ConsoleLogger(), - }, cancellationToken: playerState.StopCts.Token); + await statisticsService.LogSongPlayAsync(context.User.Id, context.User.Username, + context.User.GlobalName ?? string.Empty, song); - playerState.CurrentVoiceClient.Disconnect += HandleOnVoiceClientDisconnectedAsync; + var voiceClient = _voiceClient; + if (voiceClient is null) + { + logger.LogError("Voice client is no longer connected"); + return TrackPlayResult.Failed; + } - await playerState.CurrentVoiceClient.StartAsync(playerState.StopCts.Token); + var outStream = voiceClient.CreateVoiceStream(); + var opusStream = new OpusEncodeStream(outStream, PcmFormat.Short, VoiceChannels.Stereo, + OpusApplication.Audio); - // Register the disconnect callback - // This will be called when the cancellation token is triggered or when the voice client is closed - OnDisconnectAsync(DisconnectVoiceClientAsync); + await process.StandardOutput.BaseStream.CopyToAsync(opusStream, cancellationToken); + // Flush to make sure all the data has been sent and to indicate to Discord that we have finished sending + await opusStream.FlushAsync(cancellationToken); - await playerState.CurrentVoiceClient.EnterSpeakingStateAsync( - new SpeakingProperties(SpeakingFlags.Microphone), - cancellationToken: playerState.StopCts.Token); + await process.WaitForExitAsync(cancellationToken); + return process.ExitCode == 0 ? TrackPlayResult.Completed : TrackPlayResult.Failed; } - - async Task DisconnectVoiceClientAsync() + finally { - try - { - await client.UpdateVoiceStateAsync( - new VoiceStateProperties(_currentTrack.Context.Guild!.Id, null), - null, - playerState.StopCts.Token); - await playerState.CurrentVoiceClient.CloseAsync( - cancellationToken: playerState.StopCts.Token); - } - catch (Exception e) - { - Console.WriteLine(e); - throw; - } - finally - { - _currentTrack.RetryCount = 0; - } + await ffmpegProcessService.StopCurrentProcessAsync(); } } - finally + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - _semaphore.Release(); - logger.LogInformation("Released semaphore after handling music playing"); + return TrackPlayResult.Skipped; } } - private async Task StartFfmpegStream(string sourceUrl, OpusEncodeStream stream) + public async Task DisconnectAsync() { - var ffmpeg = - await ffmpegProcessService.CreateStreamAsync(sourceUrl, - playerState.SkipCts.Token); - - ffmpegProcessService.OnForbiddenUrlRequest += OnForbiddenUrlRequest; - ffmpegProcessService.OnPlaySongCompleted += OnPlaySongCompleted; - - await ffmpeg.StandardOutput.BaseStream.CopyToAsync(stream, - playerState.SkipCts.Token); - - // Flush 'stream' to make sure all the data has been sent and to indicate to Discord that we have finished sending - await stream.FlushAsync(playerState.SkipCts.Token); - } + var voiceClient = _voiceClient; + var gatewayClient = _gatewayClient; + var guildId = _guildId; + _voiceClient = null; + _gatewayClient = null; + _guildId = null; - private async Task GetSourceUrl(string selectedValue, IRadioSourceService radioSourceService, - IStreamService youTubeService, ulong userId, string userName, string? globalName) - { - string sourceUrl; - - if (Guid.TryParse(selectedValue, out var radioId)) - { - sourceUrl = (await radioSourceService.GetRadioSourceByIdAsync(radioId, CancellationToken.None)).SourceUrl; - } - else + try { - try + if (gatewayClient is not null && guildId is not null) { - sourceUrl = await youTubeService.GetAudioStreamUrlAsync(selectedValue, - playerState.SkipCts.Token); - } - catch (Exception ex) - { - logger.LogError(ex, "Error getting audio stream URL from YouTubeService for URL: {Url}", selectedValue); - throw; + await gatewayClient.UpdateVoiceStateAsync(new VoiceStateProperties(guildId.Value, null)); } - var song = new SongDtoBase + if (voiceClient is not null) { - Url = selectedValue, - Title = await youTubeService.GetVideoTitleAsync(selectedValue, - playerState.SkipCts.Token), - UserId = userId - }; - ffmpegProcessService.OnProcessStart += () => HandleOnProcessStartAsync(song, userId, userName, globalName); - } - - return sourceUrl; - } - - private async Task HandleOnProcessStartAsync(SongDtoBase song, ulong userId, string userName,string? globalName) - { - using var scope = serviceProvider.CreateScope(); - var statisticsService = scope.ServiceProvider - .GetRequiredService(); - await statisticsService - .LogSongPlayAsync(userId, userName, globalName ?? string.Empty, song) - .ConfigureAwait(false); - - playerState.CurrentAction = PlayerAction.Play; - } - - private async Task OnPlaySongCompleted() - { - var queuePeek = queue.Peek(); - if (queuePeek?.Id == _currentTrack?.Id) - { - logger.LogInformation($"Dequeuing track after completion: {_currentTrack?.Id}"); - queue.DequeueAsync(CancellationToken.None); + await voiceClient.CloseAsync(); + } } - - if (queue.Count == 0) + catch (Exception ex) { - await (DisconnectedVoiceClientEvent?.Invoke() ?? Task.CompletedTask); + logger.LogError(ex, "Error while disconnecting voice client"); } } - private async ValueTask HandleOnVoiceClientDisconnectedAsync(DisconnectEventArgs args) + private async Task ConnectAsync(GatewayClient client, ulong guildId, ulong channelId, + CancellationToken cancellationToken) { - ffmpegProcessService.OnForbiddenUrlRequest -= OnForbiddenUrlRequest; - ffmpegProcessService.OnPlaySongCompleted -= OnPlaySongCompleted; - await (DisconnectedVoiceClientEvent?.Invoke() ?? Task.CompletedTask); + var voiceClient = await client.JoinVoiceChannelAsync( + guildId, + channelId, + new VoiceClientConfiguration + { + Logger = new ConsoleLogger(), + }, cancellationToken: cancellationToken); + + voiceClient.Disconnect += _ => + { + logger.LogInformation("Voice client disconnected"); + // Drop the cached client so the next track triggers a fresh join. + _voiceClient = null; + return default; + }; + + await voiceClient.StartAsync(cancellationToken); + await voiceClient.EnterSpeakingStateAsync( + new SpeakingProperties(SpeakingFlags.Microphone), + cancellationToken: cancellationToken); + + _voiceClient = voiceClient; + _gatewayClient = client; + _guildId = guildId; } - private async Task OnForbiddenUrlRequest() + private static async Task<(string SourceUrl, SongDtoBase Song)> ResolveSourceAsync( + string selectedValue, + PlayRequest track, + ulong userId, + IStreamService streamService, + IRadioSourceService radioSourceService, + CancellationToken cancellationToken) { - await _semaphore.WaitAsync(); - try + if (Guid.TryParse(selectedValue, out var radioId)) { - // Retry to get new stream URL and play again - if (_currentTrack is null || playerState.CurrentAction == PlayerAction.Stop) + var radio = await radioSourceService.GetRadioSourceByIdAsync(radioId, cancellationToken); + return (radio.SourceUrl, new SongDtoBase { - logger.LogError("Current track is null or player is stopping, cannot retry"); - return; - } - - _currentTrack.RetryCount++; - if (_currentTrack.RetryCount > _maxRetryCount) - { - logger.LogError("Ffmpeg error received, maximum retry count reached, stopping playback"); - _currentTrack.RetryCount = 0; - logger.LogInformation("Dequeuing track after max retries reached"); - queue.DequeueAsync(CancellationToken.None); - if (queue.Count == 0) - { - try - { - await (DisconnectedVoiceClientEvent?.Invoke() ?? Task.CompletedTask); - } - catch (Exception ex) - { - logger.LogError(ex, "Error during disconnect event handling"); - playerState.CurrentAction = PlayerAction.Stop; - } - } - - return; - } - - logger.LogWarning( - $"Ffmpeg error received, retrying to play the stream: attempt {_currentTrack.RetryCount}/{_maxRetryCount}"); + Url = radio.SourceUrl, + Title = radio.Name, + UserId = userId + }); } - finally + + var sourceUrl = await streamService.GetAudioStreamUrlAsync(selectedValue, cancellationToken); + var title = track.VideoTitle ?? await streamService.GetVideoTitleAsync(selectedValue, cancellationToken); + return (sourceUrl, new SongDtoBase { - _semaphore.Release(); - } + Url = selectedValue, + Title = title, + UserId = userId + }); } -} \ No newline at end of file +} diff --git a/src/Infrastructure/Services/BlacklistService.cs b/src/Infrastructure/Services/BlacklistService.cs index 3bad5d1..7e1175e 100644 --- a/src/Infrastructure/Services/BlacklistService.cs +++ b/src/Infrastructure/Services/BlacklistService.cs @@ -7,40 +7,52 @@ namespace Infrastructure.Services; public class BlacklistService(DiscordBotContext context): IBlacklistService { - public Task AddToBlacklistAsync(string sourceUrl) + public async Task AddToBlacklistAsync(string sourceUrl) { if (string.IsNullOrEmpty(sourceUrl)) { throw new ArgumentException("Source URL cannot be null or empty.", nameof(sourceUrl)); } - var song = context.Songs.First(b => b.SourceUrl == sourceUrl); - + var song = await context.Songs.FirstOrDefaultAsync(b => b.SourceUrl == sourceUrl); + if (song is null) + { + return false; + } + song = Song.MarkAsBlacklisted(song, true); context.Songs.Update(song); - return context.SaveChangesAsync(); + await context.SaveChangesAsync(); + return true; } - + /// /// Removes a song from the blacklist base on title. /// Use contains to match the title. /// /// /// - public Task RemoveFromBlacklistAsync(string title) + public async Task RemoveFromBlacklistAsync(string title) { if (string.IsNullOrEmpty(title)) { throw new ArgumentException("Title cannot be null or empty.", nameof(title)); } - var song = context.Songs.First(b => EF.Functions.Like(b.Title.ToLower(), $"%{title.ToLower()}%") ); - + var pattern = $"%{EscapeLikePattern(title.ToLower())}%"; + var song = await context.Songs.FirstOrDefaultAsync(b => + EF.Functions.Like(b.Title.ToLower(), pattern, "\\")); + if (song is null) + { + return false; + } + song = Song.MarkAsBlacklisted(song, false); context.Songs.Update(song); - return context.SaveChangesAsync(); + await context.SaveChangesAsync(); + return true; } /// @@ -58,10 +70,18 @@ public Task IsBlacklistedAsync(string sourceUrl) return context.Songs.AnyAsync(b => b.SourceUrl == sourceUrl && b.IsBlacklisted); } - + // Get all blacklisted songs public Task> GetBlacklistedSongsAsync() { return context.Songs.Where(b => b.IsBlacklisted).ToListAsync(); } -} \ No newline at end of file + + private static string EscapeLikePattern(string input) + { + return input + .Replace("\\", "\\\\") + .Replace("%", "\\%") + .Replace("_", "\\_"); + } +} diff --git a/src/Infrastructure/Services/FfmpegProcessService.cs b/src/Infrastructure/Services/FfmpegProcessService.cs index 3c25d04..169a0c4 100644 --- a/src/Infrastructure/Services/FfmpegProcessService.cs +++ b/src/Infrastructure/Services/FfmpegProcessService.cs @@ -1,48 +1,40 @@ using System.Diagnostics; using Application.Interfaces.Services; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Infrastructure.Services; -public class FfmpegProcessService(ILogger logger) +public class FfmpegProcessService(ILogger logger, IConfiguration configuration) : INativePlaceMusicProcessorService, IDisposable { private Process? _ffmpegProcess; private bool _disposed; private readonly Lock _processLock = new(); - - public event Func? OnPlaySongCompleted; - public event Func? OnProcessStart; - public event Func? OnForbiddenUrlRequest; + private readonly string _ffmpegPath = configuration["Ffmpeg:Path"] ?? "/usr/bin/ffmpeg"; public async Task CreateStreamAsync(string audioUrl, CancellationToken cancellationToken) { - Process process; - - lock (_processLock) - { - DisposeCurrentProcessUnsafe(); + // Make sure any previous process is gone before starting a new one. + await StopCurrentProcessAsync(); + cancellationToken.ThrowIfCancellationRequested(); - process = new Process + var process = new Process + { + StartInfo = new ProcessStartInfo { - StartInfo = new ProcessStartInfo - { - FileName = "/usr/bin/ffmpeg", - Arguments = - $"-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5 -i \"{audioUrl}\" -f s16le -ar 48000 -ac 2 -bufsize 120k pipe:1", - RedirectStandardOutput = true, - RedirectStandardError = true, - RedirectStandardInput = true, - UseShellExecute = false, - CreateNoWindow = true, - }, - EnableRaisingEvents = true - }; - - _ffmpegProcess = process; - } + FileName = _ffmpegPath, + Arguments = + $"-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5 -i \"{audioUrl}\" -f s16le -ar 48000 -ac 2 -bufsize 120k pipe:1", + RedirectStandardOutput = true, + RedirectStandardError = true, + RedirectStandardInput = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + EnableRaisingEvents = true + }; - // Set up logging process.ErrorDataReceived += (_, e) => { if (string.IsNullOrEmpty(e.Data)) return; @@ -57,130 +49,73 @@ public async Task CreateStreamAsync(string audioUrl, CancellationToken logger.Log(level, "FFmpeg: {Message}", e.Data); }; - process.Exited += async (sender, _) => - { - Process? exitedProcess = sender as Process; - if (exitedProcess == null) return; - - try - { - var exitCode = exitedProcess.ExitCode; - - if (exitCode == 0) - { - // Handle normal completion - logger.LogInformation("FFmpeg stream completed successfully (PID: {ProcessId})", exitedProcess.Id); - await (OnPlaySongCompleted?.Invoke() ?? Task.CompletedTask); - } - else - { - // Handle error - maybe retry or skip to next track - logger.LogError("FFmpeg process exited with error code: {ExitCode} (PID: {ProcessId})", - exitCode, exitedProcess.Id); - await Task.Delay(500); // Brief delay to ensure logs are flushed - await (OnForbiddenUrlRequest?.Invoke() ?? Task.CompletedTask); - } - } - catch (InvalidOperationException ex) - { - // Process might have been disposed already - logger.LogDebug(ex, "Could not read exit code - process may have been disposed"); - } - catch (Exception ex) - { - logger.LogError(ex, "Error in FFmpeg Exited event handler"); - } - }; - - // Handle cancellation - cancellationToken.Register(() => - { - logger.LogInformation("Cancellation requested for FFmpeg process"); - DisposeCurrentProcess(); - }); - - // Start the process try { if (!process.Start()) { - lock (_processLock) - { - if (_ffmpegProcess == process) - { - _ffmpegProcess = null; - } - } - process.Dispose(); throw new InvalidOperationException("Failed to start ffmpeg process."); } process.BeginErrorReadLine(); - - logger.LogInformation("FFmpeg process started for URL: {AudioUrl} (PID: {ProcessId})", + + logger.LogInformation("FFmpeg process started for URL: {AudioUrl} (PID: {ProcessId})", audioUrl, process.Id); - - await (OnProcessStart?.Invoke() ?? Task.CompletedTask); - - return process; } catch { - lock (_processLock) - { - if (_ffmpegProcess == process) - { - _ffmpegProcess = null; - } - } process.Dispose(); throw; } - } - private void DisposeCurrentProcess() - { lock (_processLock) { - DisposeCurrentProcessUnsafe(); + _ffmpegProcess = process; } + + return process; } - private void DisposeCurrentProcessUnsafe() + public async Task StopCurrentProcessAsync() { - if (_ffmpegProcess == null) return; + Process? process; + lock (_processLock) + { + process = _ffmpegProcess; + _ffmpegProcess = null; + } + + if (process is null) return; - var process = _ffmpegProcess; - _ffmpegProcess = null; + await TerminateProcessAsync(process); + } - // Check if already exited before trying to terminate + private async Task TerminateProcessAsync(Process process) + { try { - if (process.HasExited) + try { - logger.LogDebug("FFmpeg process already exited (PID: {ProcessId})", process.Id); - process.Dispose(); + if (process.HasExited) + { + logger.LogDebug("FFmpeg process already exited (PID: {ProcessId})", process.Id); + return; + } + } + catch (InvalidOperationException) + { + // Process was never started or already disposed return; } - } - catch (InvalidOperationException) - { - // Process was never started or already disposed - process.Dispose(); - return; - } - logger.LogInformation("Terminating FFmpeg process (PID: {ProcessId})...", process.Id); + logger.LogInformation("Terminating FFmpeg process (PID: {ProcessId})...", process.Id); - try - { // Try graceful shutdown first try { if (!process.HasExited) { - process.StandardInput?.WriteLine("q"); - process.StandardInput?.Close(); + process.StandardInput.WriteLine("q"); + process.StandardInput.Close(); } } catch (InvalidOperationException) @@ -188,28 +123,25 @@ private void DisposeCurrentProcessUnsafe() // Process might have exited between checks } - // Wait briefly for graceful exit - if (!process.WaitForExit(1500)) + if (await WaitForExitAsync(process, TimeSpan.FromMilliseconds(1500))) { - // Force kill if still running - try - { - if (!process.HasExited) - { - process.Kill(entireProcessTree: true); - process.WaitForExit(3000); - logger.LogInformation("FFmpeg process killed (PID: {ProcessId})", process.Id); - } - } - catch (InvalidOperationException) + logger.LogInformation("FFmpeg process terminated gracefully (PID: {ProcessId})", process.Id); + return; + } + + // Force kill if still running + try + { + if (!process.HasExited) { - // Process exited between check and kill - logger.LogDebug("Process exited before kill command"); + process.Kill(entireProcessTree: true); + await WaitForExitAsync(process, TimeSpan.FromMilliseconds(3000)); + logger.LogInformation("FFmpeg process killed (PID: {ProcessId})", process.Id); } } - else + catch (InvalidOperationException) { - logger.LogInformation("FFmpeg process terminated gracefully (PID: {ProcessId})", process.Id); + logger.LogDebug("Process exited before kill command"); } } catch (Exception ex) @@ -229,15 +161,57 @@ private void DisposeCurrentProcessUnsafe() } } + private static async Task WaitForExitAsync(Process process, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + try + { + await process.WaitForExitAsync(cts.Token); + return true; + } + catch (OperationCanceledException) + { + return false; + } + } + public void Dispose() { if (_disposed) return; - + + Process? process; lock (_processLock) { if (_disposed) return; _disposed = true; - DisposeCurrentProcessUnsafe(); + process = _ffmpegProcess; + _ffmpegProcess = null; + } + + if (process is null) return; + + // Best-effort synchronous kill during host shutdown. + try + { + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } + catch (Exception ex) + { + logger.LogDebug(ex, "Error killing FFmpeg process during dispose"); + } + finally + { + try + { + process.Dispose(); + } + catch + { + // ignored + } } } -} \ No newline at end of file +} diff --git a/src/Infrastructure/Services/MusicPlayerBackgroundService.cs b/src/Infrastructure/Services/MusicPlayerBackgroundService.cs new file mode 100644 index 0000000..297dbe1 --- /dev/null +++ b/src/Infrastructure/Services/MusicPlayerBackgroundService.cs @@ -0,0 +1,162 @@ +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 +/// 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 . +/// +public sealed class MusicPlayerBackgroundService( + IMusicQueueService queue, + INetCordAudioPlayerService audioPlayer, + ILogger logger) + : BackgroundService, IMusicPlayerController +{ + private const int MaxRetryCount = 3; + + private readonly Lock _ctsLock = new(); + private CancellationTokenSource? _trackCts; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + PlayRequest request; + try + { + request = await queue.DequeueAsync(stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + + queue.SetNowPlaying(request); + CancellationToken trackToken; + lock (_ctsLock) + { + _trackCts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + trackToken = _trackCts.Token; + } + + try + { + await PlayWithRetryAsync(request, trackToken); + } + finally + { + queue.SetNowPlaying(null); + lock (_ctsLock) + { + _trackCts.Dispose(); + _trackCts = null; + } + } + + if (queue.Count == 0 && !stoppingToken.IsCancellationRequested) + { + logger.LogInformation("Queue is empty - disconnecting from voice channel"); + try + { + await audioPlayer.DisconnectAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error disconnecting voice client"); + } + + await SafeCallbackAsync(request, + "Disconnected from voice channel either due to inactivity or error encountered."); + } + } + } + + private async Task PlayWithRetryAsync(PlayRequest request, CancellationToken trackToken) + { + for (var attempt = 1; attempt <= MaxRetryCount; attempt++) + { + TrackPlayResult result; + try + { + result = await audioPlayer.PlayTrackAsync(request, trackToken); + } + catch (OperationCanceledException) when (trackToken.IsCancellationRequested) + { + logger.LogInformation("Track {TrackId} cancelled (skip/stop)", request.Id); + return; + } + catch (Exception ex) + { + logger.LogError(ex, "Error playing track {TrackId} (attempt {Attempt}/{Max})", + request.Id, attempt, MaxRetryCount); + result = TrackPlayResult.Failed; + } + + switch (result) + { + case TrackPlayResult.Completed: + case TrackPlayResult.Skipped: + return; + + case TrackPlayResult.NotInVoiceChannel: + await SafeCallbackAsync(request, "You are not connected to any voice channel!"); + return; + + case TrackPlayResult.Failed when attempt < MaxRetryCount: + logger.LogWarning("Track {TrackId} failed, retrying: attempt {Attempt}/{Max}", + request.Id, attempt, MaxRetryCount); + continue; + + default: + logger.LogError("Track {TrackId} failed after {Max} attempts, dropping it", + request.Id, MaxRetryCount); + return; + } + } + } + + public void Skip() + { + CancellationTokenSource? cts; + lock (_ctsLock) + { + cts = _trackCts; + } + + try + { + cts?.Cancel(); + } + catch (ObjectDisposedException) + { + // The track finished between the read and the cancel; nothing to skip. + } + } + + public void Stop() + { + queue.Clear(); + Skip(); + } + + private async Task SafeCallbackAsync(PlayRequest request, string message) + { + try + { + await request.Callbacks.Invoke(message); + } + catch (Exception ex) + { + // An interaction can only be responded to once; a failed notification must not stop the player. + logger.LogWarning(ex, "Failed to send message to Discord: {Message}", message); + } + } +} diff --git a/src/Infrastructure/Services/MusicQueueService.cs b/src/Infrastructure/Services/MusicQueueService.cs index 8a67bee..da5a888 100644 --- a/src/Infrastructure/Services/MusicQueueService.cs +++ b/src/Infrastructure/Services/MusicQueueService.cs @@ -1,76 +1,101 @@ +using System.Threading.Channels; using Application.DTOs; using Application.Interfaces.Services; -using Microsoft.Extensions.Logging; using NetCord.Services.ComponentInteractions; namespace Infrastructure.Services; -public class MusicQueueService(ILogger logger) : IMusicQueueService +/// +/// In-memory music queue following the channel-backed queue service pattern: +/// a lock-protected list is the source of truth and an unbounded channel is the +/// async signal consumed by the single player background service. +/// Every enqueue writes exactly one signal; a dequeue consumes one signal per +/// returned item, so stale signals left behind by are +/// absorbed harmlessly (the consumer finds the list empty and waits again). +/// +public class MusicQueueService : IMusicQueueService { - private readonly Queue> _queue = new(); - private readonly SemaphoreSlim _signal = new(0); + private readonly Channel _signal = Channel.CreateUnbounded(); + private readonly List> _items = []; private readonly Lock _lock = new(); + private PlayRequest? _nowPlaying; public void Enqueue(PlayRequest request) { if (request is not PlayRequest playRequest) { - throw new ArgumentException($"Invalid request type. Expected {typeof(PlayRequest)}", nameof(request)); + throw new ArgumentException( + $"Invalid request type. Expected {typeof(PlayRequest)}", + nameof(request)); } + lock (_lock) { - _queue.Enqueue(playRequest); - _signal.Release(); + _items.Add(playRequest); } + + _signal.Writer.TryWrite(true); } - public PlayRequest? Peek() + public async ValueTask> DequeueAsync(CancellationToken cancellationToken) { - - lock (_lock) + while (true) { - if (!_queue.TryPeek(out var result) || result is not PlayRequest currentRequest) + await _signal.Reader.ReadAsync(cancellationToken); + + lock (_lock) { - logger.LogWarning("Peeked request is not of the expected type {ExpectedType}", typeof(PlayRequest)); - return null; + if (_items.Count > 0) + { + var item = _items[0]; + _items.RemoveAt(0); + return item as PlayRequest + ?? throw new InvalidOperationException( + $"Queued request is not of the expected type {typeof(PlayRequest)}."); + } } - - return _queue.Count > 0 ? currentRequest : null; + + // Stale signal (the queue was cleared since the signal was written); wait for the next enqueue. } } - public async void DequeueAsync(CancellationToken cancellationToken) + public int Count { - await _signal.WaitAsync(cancellationToken); - lock (_lock) + get { - if (_queue.Count > 0) - { - _queue.Dequeue(); - } - else + lock (_lock) { - logger.LogWarning("Attempted to dequeue from an empty queue."); + return _items.Count; } } } - public int Count + public PlayRequest? NowPlaying { get { lock (_lock) { - return _queue.Count; + return _nowPlaying; } } } + public void SetNowPlaying(PlayRequest? request) + { + lock (_lock) + { + _nowPlaying = request; + } + } + public PlayRequest[] GetAllRequests() { lock (_lock) { - return _queue.OfType().ToArray(); + return _nowPlaying is null + ? _items.ToArray() + : [_nowPlaying, .. _items]; } } @@ -78,35 +103,23 @@ public void Rewind() { lock (_lock) { - if (_queue.Count > 1) + if (_nowPlaying is not PlayRequest current) { - var current = _queue.Peek(); - var list = _queue.ToList(); - list.Reverse(); - list.Add(current); - list.Reverse(); - _queue.Clear(); - foreach (var item in list) - { - _queue.Enqueue(item); - } - } - else - { - _queue.Enqueue(_queue.Peek()); + return; } + + current.RetryCount = 0; + _items.Insert(0, current); } + + _signal.Writer.TryWrite(true); } public void Clear() { lock (_lock) { - _queue.Clear(); - while (_signal.CurrentCount > 0) - { - _signal.Wait(); - } + _items.Clear(); } } -} \ No newline at end of file +} diff --git a/src/Infrastructure/Services/PlayerHandler.cs b/src/Infrastructure/Services/PlayerHandler.cs index 170176f..a6c16f0 100644 --- a/src/Infrastructure/Services/PlayerHandler.cs +++ b/src/Infrastructure/Services/PlayerHandler.cs @@ -2,151 +2,25 @@ using Domain.Common; using Domain.Eventing; using Microsoft.Extensions.Logging; -using NetCord.Gateway.Voice; -using NetCord.Services.ComponentInteractions; namespace Infrastructure.Services; -public class PlayerHandler( - IMusicQueueService queue, - ILogger logger, - INetCordAudioPlayerService playerService, - PlayerState playerState) - : IEventHandler, IEventHandler, IEventHandler +/// +/// Thin adapter translating Skip/Stop events into player controller signals. +/// Playback itself is driven by . +/// +public class PlayerHandler(IMusicPlayerController playerController, ILogger logger) + : IEventHandler, IEventHandler { - public async void Handle(EventType.Play @event) + public void Handle(EventType.Skip @event) { - if (playerState.CurrentAction != PlayerAction.Stop) - { - logger.LogInformation("Play event received - but already playing"); - return; - } - - await HandlePlayNextAsync(); - } - - public async void Handle(EventType.Skip @event) - { - logger.LogInformation("Skip event received - cancelling current play"); - - // 1 indicates only the currently playing item - // skip action should be ignored - if (queue.Count <= 1) - { - logger.LogInformation("Skip event received - but queue is empty, ignored"); - return; - } - - await playerState.SkipCts.CancelAsync()!; - playerState.SkipCts.Dispose(); - playerState.SkipCts = null!; - - queue.DequeueAsync(CancellationToken.None); - - playerState.CurrentAction = PlayerAction.Skip; - await HandlePlayNextAsync(); - } - - public async void Handle(EventType.Stop @event) - { - await DisconnectVoiceClient(); - queue.Clear(); - } - - - private async Task HandlePlayNextAsync() - { - try - { - playerState.StopCts ??= new CancellationTokenSource(); - - var request = queue.Peek(); - if (request is null) - { - logger.LogInformation("No next item in queue"); - return; - } - - do - { - playerState.SkipCts?.Dispose(); - playerState.SkipCts = CancellationTokenSource.CreateLinkedTokenSource(playerState.StopCts.Token); - - playerState.StopCts.Token.Register(async void () => - { - try - { - request.Context.Client.Disconnect += _ => - { - logger.LogInformation("Client disconnected"); - return default; - }; - } - catch (Exception e) - { - logger.LogError(e, "Error during disconnect"); - } - }); - - playerService.NotInVoiceChannelCallback += async () => - { - await request.Callbacks.Invoke("You are not connected to any voice channel!"); - }; - - playerService.DisconnectedVoiceClientEvent += async () => - { - logger.LogInformation("Voice client disconnected - stopping playback"); - await DisconnectVoiceClient(); - await request.Callbacks.Invoke( - "Disconnected from voice channel either due to inactivity or error encountered."); - }; - - await playerService.Play(SetDisconnectCallback); - } while (!playerState.StopCts.Token.IsCancellationRequested && queue.Count > 0); - - if (queue.Count == 0) - { - logger.LogInformation("Queue is empty - stopping playback"); - await DisconnectVoiceClient(); - } - } - catch (OperationCanceledException) when (playerState.StopCts?.Token.IsCancellationRequested == true) - { - logger.LogInformation("Player loop was cancelled (Stop event)"); - } - catch (Exception e) - { - logger.LogError(e, "Error in NetCordPlayerHandler"); - } - } - - private void SetDisconnectCallback(Func onDisconnectAsync) - { - playerState.DisconnectAsyncCallback = onDisconnectAsync; + logger.LogInformation("Skip event received - cancelling current track"); + playerController.Skip(); } - private async Task DisconnectVoiceClient() + public void Handle(EventType.Stop @event) { - try - { - logger.LogInformation("Stop event received - cancelling loop"); - if (playerState.DisconnectAsyncCallback is not null) - { - await playerState.DisconnectAsyncCallback(); - } - } - catch (Exception e) - { - logger.LogError(e, "Error during disconnect"); - } - finally - { - await playerState.StopCts?.CancelAsync()!; - playerState.StopCts?.Dispose(); - playerState.StopCts = null!; - playerState.CurrentAction = PlayerAction.Stop; - playerState.DisconnectAsyncCallback = null; - playerState.CurrentVoiceClient = null; - } + logger.LogInformation("Stop event received - clearing queue and stopping playback"); + playerController.Stop(); } -} \ No newline at end of file +} diff --git a/src/Infrastructure/Services/RadioSourceService.cs b/src/Infrastructure/Services/RadioSourceService.cs index 44714cf..daf9a35 100644 --- a/src/Infrastructure/Services/RadioSourceService.cs +++ b/src/Infrastructure/Services/RadioSourceService.cs @@ -25,7 +25,7 @@ public async Task UpdateRadioSourceUrlAsync(Guid id, string name, string newSour throw new ArgumentException("Source URL cannot be null or empty.", nameof(newSourceUrl)); } - var radioSource = context.RadioSources.FirstOrDefault(rs => rs.Id == id); + var radioSource = await context.RadioSources.FirstOrDefaultAsync(rs => rs.Id == id, cancellationToken); if (radioSource == null) { throw new KeyNotFoundException($"Radio source with ID {id} not found."); diff --git a/src/Infrastructure/Services/SoundCloudService.cs b/src/Infrastructure/Services/SoundCloudService.cs index 272a5d9..82385eb 100644 --- a/src/Infrastructure/Services/SoundCloudService.cs +++ b/src/Infrastructure/Services/SoundCloudService.cs @@ -10,20 +10,23 @@ public async Task GetAudioStreamUrlAsync(string url, CancellationToken c { try { - await soundCloudClient.InitializeAsync(); - var audioStream = await soundCloudClient.Tracks.GetDownloadUrlAsync(url) ?? throw new InvalidOperationException("No suitable audio format found."); + await soundCloudClient.InitializeAsync(cancellationToken); + var audioStream = await soundCloudClient.Tracks.GetDownloadUrlAsync(url, cancellationToken) + ?? throw new InvalidOperationException("No suitable audio format found."); return audioStream; } catch (Exception ex) { logger.LogWarning(ex, "SoundCloudClient failed for: {Url}", url); - return null!; + throw; } } - public Task GetVideoTitleAsync(string url, CancellationToken cancellationToken) + public async Task GetVideoTitleAsync(string url, CancellationToken cancellationToken) { - throw new NotImplementedException(); + await soundCloudClient.InitializeAsync(cancellationToken); + var track = await soundCloudClient.Tracks.GetAsync(url, cancellationToken); + return track?.Title ?? throw new InvalidOperationException($"Could not resolve track title for: {url}"); } -} \ No newline at end of file +} diff --git a/src/Infrastructure/Services/StatisticsService.cs b/src/Infrastructure/Services/StatisticsService.cs index 1952100..3d5cf4b 100644 --- a/src/Infrastructure/Services/StatisticsService.cs +++ b/src/Infrastructure/Services/StatisticsService.cs @@ -5,11 +5,15 @@ using Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Song = Domain.Entities.Song; namespace Infrastructure.Services; -public class StatisticsService(DiscordBotContext context, [FromKeyedServices(nameof(YoutubeService))] IStreamService streamService) +public class StatisticsService( + DiscordBotContext context, + [FromKeyedServices(nameof(YoutubeService))] IStreamService streamService, + ILogger logger) : IStatisticsService { // Log when a user plays a song @@ -31,13 +35,17 @@ public async Task LogSongPlayAsync(ulong id, string userName, string globalName, // Find or create song var song = await context.Songs - .FirstOrDefaultAsync(s => EF.Functions.Like(s.Title, songDto.Title)) ?? - await context.Songs - .FirstOrDefaultAsync(s => s.SourceUrl == songDto.Url); + .FirstOrDefaultAsync(s => s.SourceUrl == songDto.Url) ?? + (string.IsNullOrEmpty(songDto.Title) + ? null + : await context.Songs + .FirstOrDefaultAsync(s => s.Title.ToLower() == songDto.Title.ToLower())); if (song == null) { - var songTitle = await streamService.GetVideoTitleAsync(songDto.Url, CancellationToken.None); + var songTitle = string.IsNullOrEmpty(songDto.Title) + ? await streamService.GetVideoTitleAsync(songDto.Url, CancellationToken.None) + : songDto.Title; song = Song.Create(songDto.Url, songTitle); context.Songs.Add(song); } @@ -69,7 +77,7 @@ await context.Songs catch (Exception ex) { // Log error but don't break the music bot - Console.WriteLine($"Error logging song play: {ex.Message}"); + logger.LogError(ex, "Error logging song play for user {UserId} and URL {Url}", id, songDto.Url); } } diff --git a/src/Infrastructure/Services/UserService.cs b/src/Infrastructure/Services/UserService.cs index 576d1f9..cba441d 100644 --- a/src/Infrastructure/Services/UserService.cs +++ b/src/Infrastructure/Services/UserService.cs @@ -33,7 +33,10 @@ public async Task> GetAllUsersAsync() MemberSince = u.CreatedAt, TotalPlays = u.PlayHistories.Sum(ph => ph.TotalPlays), UniqueSongs = u.PlayHistories.Select(ph => ph.Song).Distinct().Count(), - LastPlayed = u.PlayHistories.OrderByDescending(ph => ph.PlayedAt).FirstOrDefault()!.PlayedAt, + LastPlayed = u.PlayHistories + .OrderByDescending(ph => ph.PlayedAt) + .Select(ph => (DateTimeOffset?)ph.PlayedAt) + .FirstOrDefault(), DisplayName = u.DisplayName ?? u.Username }) .OrderByDescending(u => u.TotalPlays) diff --git a/src/Infrastructure/Services/YoutubeService.cs b/src/Infrastructure/Services/YoutubeService.cs index 4d25287..3acdf5d 100644 --- a/src/Infrastructure/Services/YoutubeService.cs +++ b/src/Infrastructure/Services/YoutubeService.cs @@ -11,7 +11,7 @@ public class YoutubeService: IStreamService { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; - private readonly List>> _providerStrategy; + private readonly List>> _providerStrategy; private readonly YoutubeClient _youtubeClient; public YoutubeService( @@ -34,10 +34,10 @@ public async Task GetAudioStreamUrlAsync(string url, CancellationToken c { foreach (var strategy in _providerStrategy) { - var result = await ExecuteWithTimeout(strategy, url, TimeSpan.FromSeconds(15)); + var result = await ExecuteWithTimeout(strategy, url, TimeSpan.FromSeconds(15), cancellationToken); if (result.Success) { - _logger.LogInformation("Successfully obtained stream URL using {Provider}", + _logger.LogInformation("Successfully obtained stream URL using {Provider}", strategy.Method.Name.Replace("TryGetWith", "").Replace("Async", "")); return result.Url!; } @@ -48,32 +48,35 @@ public async Task GetAudioStreamUrlAsync(string url, CancellationToken c } private async Task<(bool Success, string? Url)> ExecuteWithTimeout( - Func> func, + Func> func, string url, - TimeSpan timeout) + TimeSpan timeout, + CancellationToken cancellationToken) { try { - var task = func(url); - var completedTask = await Task.WhenAny(task, Task.Delay(timeout)); - - if (completedTask == task) - return await task; - - _logger.LogWarning("Provider {Provider} timed out after {Timeout} seconds", - func.Method.Name.Replace("TryGetWith", "").Replace("Async", ""), + return await func(url, cancellationToken).WaitAsync(timeout, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (TimeoutException) + { + _logger.LogWarning("Provider {Provider} timed out after {Timeout} seconds", + func.Method.Name.Replace("TryGetWith", "").Replace("Async", ""), timeout.TotalSeconds); return (false, null); } catch (Exception ex) { - _logger.LogWarning(ex, "Provider {Provider} failed", + _logger.LogWarning(ex, "Provider {Provider} failed", func.Method.Name.Replace("TryGetWith", "").Replace("Async", "")); return (false, null); } } - private async Task<(bool Success, string? Url)> TryGetWithYtDlpAsync(string url) + private async Task<(bool Success, string? Url)> TryGetWithYtDlpAsync(string url, CancellationToken cancellationToken) { try { @@ -82,7 +85,7 @@ public async Task GetAudioStreamUrlAsync(string url, CancellationToken c { Format = "bestaudio/best" }; - var result = await ytdl.RunVideoDataFetch(url, overrideOptions: overrideOptions); + var result = await ytdl.RunVideoDataFetch(url, ct: cancellationToken, overrideOptions: overrideOptions); if (!result.Success || result.Data?.Formats == null) { @@ -99,7 +102,7 @@ public async Task GetAudioStreamUrlAsync(string url, CancellationToken c .MaxBy(f => f.AudioBitrate ?? 0) ?? httpsFormats .MaxBy(f => f.Bitrate ?? 0); - + if (bestAudio?.Url == null) { _logger.LogWarning("YT-DLP found no suitable audio format for: {Url}", url); @@ -108,6 +111,10 @@ public async Task GetAudioStreamUrlAsync(string url, CancellationToken c return (true, bestAudio.Url); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { _logger.LogWarning(ex, "YT-DLP failed for: {Url}", url); @@ -115,26 +122,30 @@ public async Task GetAudioStreamUrlAsync(string url, CancellationToken c } } - private async Task<(bool Success, string? Url)> TryGetWithYoutubeExplodeAsync(string url) + private async Task<(bool Success, string? Url)> TryGetWithYoutubeExplodeAsync(string url, CancellationToken cancellationToken) { try { using var scope = _serviceProvider.CreateScope(); var youtubeClient = scope.ServiceProvider.GetRequiredService(); - var manifest = await youtubeClient.Videos.Streams.GetManifestAsync(url); + var manifest = await youtubeClient.Videos.Streams.GetManifestAsync(url, cancellationToken); var audioStream = manifest.GetAudioOnlyStreams().GetWithHighestBitrate(); return (true, audioStream.Url); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { _logger.LogWarning(ex, "YoutubeExplode failed for: {Url}", url); return (false, null); } } - + public async Task GetVideoTitleAsync(string url, CancellationToken cancellationToken) { return (await _youtubeClient.Videos.GetAsync(url, cancellationToken)).Title; } -} \ No newline at end of file +} diff --git a/src/Tests/Integration/BlacklistServiceTests.cs b/src/Tests/Integration/BlacklistServiceTests.cs new file mode 100644 index 0000000..cffe9a9 --- /dev/null +++ b/src/Tests/Integration/BlacklistServiceTests.cs @@ -0,0 +1,116 @@ +using Domain.Entities; +using Infrastructure.Services; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace Tests.Integration; + +[Collection("postgres")] +public class BlacklistServiceTests(PostgresFixture fixture) +{ + private static string UniqueUrl() => $"https://youtube.com/watch?v={Guid.NewGuid():N}"; + + private async Task SeedSongAsync(string url, string title, bool blacklisted = false) + { + await using var context = fixture.CreateContext(); + var song = Song.Create(url, title); + if (blacklisted) + { + Song.MarkAsBlacklisted(song, true); + } + + context.Songs.Add(song); + await context.SaveChangesAsync(); + return song; + } + + [Fact] + public async Task AddToBlacklist_Marks_Existing_Song_And_Returns_True() + { + var url = UniqueUrl(); + await SeedSongAsync(url, $"Song {Guid.NewGuid():N}"); + + await using (var context = fixture.CreateContext()) + { + var service = new BlacklistService(context); + Assert.True(await service.AddToBlacklistAsync(url)); + } + + await using (var context = fixture.CreateContext()) + { + var service = new BlacklistService(context); + Assert.True(await service.IsBlacklistedAsync(url)); + } + } + + [Fact] + public async Task AddToBlacklist_Returns_False_When_Song_Not_Found() + { + await using var context = fixture.CreateContext(); + var service = new BlacklistService(context); + + Assert.False(await service.AddToBlacklistAsync(UniqueUrl())); + } + + [Fact] + public async Task RemoveFromBlacklist_By_Partial_Title_Returns_True() + { + var url = UniqueUrl(); + var marker = Guid.NewGuid().ToString("N"); + await SeedSongAsync(url, $"Some Great Song {marker}", blacklisted: true); + + await using (var context = fixture.CreateContext()) + { + var service = new BlacklistService(context); + Assert.True(await service.RemoveFromBlacklistAsync(marker)); + } + + await using (var context = fixture.CreateContext()) + { + var service = new BlacklistService(context); + Assert.False(await service.IsBlacklistedAsync(url)); + } + } + + [Fact] + public async Task RemoveFromBlacklist_Returns_False_When_Not_Found() + { + await using var context = fixture.CreateContext(); + var service = new BlacklistService(context); + + Assert.False(await service.RemoveFromBlacklistAsync(Guid.NewGuid().ToString("N"))); + } + + [Fact] + public async Task RemoveFromBlacklist_Treats_Like_Wildcards_As_Literals() + { + var url = UniqueUrl(); + var marker = Guid.NewGuid().ToString("N"); + await SeedSongAsync(url, $"Wildcard {marker}", blacklisted: true); + + await using var context = fixture.CreateContext(); + var service = new BlacklistService(context); + + // "%" would match everything if it were not escaped; it must not match this song. + Assert.False(await service.RemoveFromBlacklistAsync($"{marker}%extra")); + Assert.True(await service.IsBlacklistedAsync(url)); + } + + [Fact] + public async Task IsBlacklisted_Reflects_Flag() + { + var url = UniqueUrl(); + await SeedSongAsync(url, $"Song {Guid.NewGuid():N}"); + + await using var context = fixture.CreateContext(); + var service = new BlacklistService(context); + + Assert.False(await service.IsBlacklistedAsync(url)); + + var song = await context.Songs.FirstAsync(s => s.SourceUrl == url); + Song.MarkAsBlacklisted(song, true); + await context.SaveChangesAsync(); + + Assert.True(await service.IsBlacklistedAsync(url)); + } +} diff --git a/src/Tests/Integration/PostgresFixture.cs b/src/Tests/Integration/PostgresFixture.cs new file mode 100644 index 0000000..e968607 --- /dev/null +++ b/src/Tests/Integration/PostgresFixture.cs @@ -0,0 +1,41 @@ +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Testcontainers.PostgreSql; +using Xunit; + +namespace Tests.Integration; + +/// +/// Spins up one PostgreSQL container for the whole integration collection and +/// applies the real EF Core migrations. Tests share the database, so every +/// test must use unique identifiers (URLs, usernames, user ids). +/// Requires a local Docker daemon. +/// +public sealed class PostgresFixture : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder() + .WithImage("postgres:17-alpine") + .Build(); + + public async Task InitializeAsync() + { + await _container.StartAsync(); + + await using var context = CreateContext(); + await context.Database.MigrateAsync(); + } + + public Task DisposeAsync() => _container.DisposeAsync().AsTask(); + + public DiscordBotContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(_container.GetConnectionString()) + .Options; + + return new DiscordBotContext(options); + } +} + +[CollectionDefinition("postgres")] +public sealed class PostgresCollection : ICollectionFixture; diff --git a/src/Tests/Integration/StatisticsServiceTests.cs b/src/Tests/Integration/StatisticsServiceTests.cs new file mode 100644 index 0000000..1c63bb4 --- /dev/null +++ b/src/Tests/Integration/StatisticsServiceTests.cs @@ -0,0 +1,118 @@ +using Application.DTOs; +using Application.Interfaces.Services; +using Infrastructure.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace Tests.Integration; + +[Collection("postgres")] +public class StatisticsServiceTests(PostgresFixture fixture) +{ + private static ulong UniqueUserId() => + (ulong)Random.Shared.NextInt64(1_000_000, long.MaxValue); + + private static string UniqueUrl() => $"https://youtube.com/watch?v={Guid.NewGuid():N}"; + + [Fact] + public async Task LogSongPlay_Creates_User_Song_And_PlayHistory() + { + var userId = UniqueUserId(); + var userName = $"user-{Guid.NewGuid():N}"; + var url = UniqueUrl(); + var title = $"Song {Guid.NewGuid():N}"; + var streamService = Substitute.For(); + + await using (var context = fixture.CreateContext()) + { + var service = new StatisticsService(context, streamService, + NullLogger.Instance); + await service.LogSongPlayAsync(userId, userName, "Global Name", + new SongDtoBase { Url = url, Title = title, UserId = userId }); + } + + await using (var context = fixture.CreateContext()) + { + var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId); + Assert.NotNull(user); + Assert.Equal(userName, user.Username); + Assert.Equal(1, user.TotalSongsPlayed); + + var song = await context.Songs.FirstOrDefaultAsync(s => s.SourceUrl == url); + Assert.NotNull(song); + Assert.Equal(title, song.Title); + + var history = await context.PlayHistory + .FirstOrDefaultAsync(ph => ph.UserId == userId && ph.SongId == song.Id); + Assert.NotNull(history); + Assert.Equal(1, history.TotalPlays); + } + } + + [Fact] + public async Task LogSongPlay_Increments_TotalPlays_For_Repeat_Play() + { + var userId = UniqueUserId(); + var userName = $"user-{Guid.NewGuid():N}"; + var url = UniqueUrl(); + var title = $"Song {Guid.NewGuid():N}"; + var streamService = Substitute.For(); + var songDto = new SongDtoBase { Url = url, Title = title, UserId = userId }; + + await using (var context = fixture.CreateContext()) + { + var service = new StatisticsService(context, streamService, + NullLogger.Instance); + await service.LogSongPlayAsync(userId, userName, "Global Name", songDto); + } + + await using (var context = fixture.CreateContext()) + { + var service = new StatisticsService(context, streamService, + NullLogger.Instance); + await service.LogSongPlayAsync(userId, userName, "Global Name", songDto); + } + + await using (var context = fixture.CreateContext()) + { + var song = await context.Songs.SingleAsync(s => s.SourceUrl == url); + var histories = await context.PlayHistory + .Where(ph => ph.UserId == userId && ph.SongId == song.Id) + .ToListAsync(); + + // Deduplicated: one history row whose counter was incremented. + var history = Assert.Single(histories); + Assert.Equal(2, history.TotalPlays); + + var user = await context.Users.SingleAsync(u => u.Id == userId); + Assert.Equal(2, user.TotalSongsPlayed); + } + } + + [Fact] + public async Task LogSongPlay_Uses_Provided_Title_Without_Calling_StreamService() + { + var userId = UniqueUserId(); + var url = UniqueUrl(); + var title = $"Radio Station {Guid.NewGuid():N}"; + var streamService = Substitute.For(); + + await using (var context = fixture.CreateContext()) + { + var service = new StatisticsService(context, streamService, + NullLogger.Instance); + await service.LogSongPlayAsync(userId, $"user-{Guid.NewGuid():N}", string.Empty, + new SongDtoBase { Url = url, Title = title, UserId = userId }); + } + + await streamService.DidNotReceiveWithAnyArgs().GetVideoTitleAsync(default!, default); + + await using (var context = fixture.CreateContext()) + { + var song = await context.Songs.SingleAsync(s => s.SourceUrl == url); + Assert.Equal(title, song.Title); + } + } +} diff --git a/src/Tests/Integration/UserServiceTests.cs b/src/Tests/Integration/UserServiceTests.cs new file mode 100644 index 0000000..e9d2d2c --- /dev/null +++ b/src/Tests/Integration/UserServiceTests.cs @@ -0,0 +1,80 @@ +using Domain.Entities; +using Infrastructure.Services; +using Xunit; + +namespace Tests.Integration; + +[Collection("postgres")] +public class UserServiceTests(PostgresFixture fixture) +{ + private static ulong UniqueUserId() => + (ulong)Random.Shared.NextInt64(1_000_000, long.MaxValue); + + [Fact] + public async Task GetAllUsers_Returns_Null_LastPlayed_For_User_With_No_History() + { + var username = $"user-{Guid.NewGuid():N}"; + + await using (var context = fixture.CreateContext()) + { + context.Users.Add(User.Create(UniqueUserId(), username, "No History")); + await context.SaveChangesAsync(); + } + + await using (var freshContext = fixture.CreateContext()) + { + var service = new UserService(freshContext); + var users = await service.GetAllUsersAsync(); + + var user = users.SingleOrDefault(u => u.Username == username); + Assert.NotNull(user); + Assert.Null(user.LastPlayed); + Assert.Equal(0, user.TotalPlays); + } + } + + [Fact] + public async Task GetAllUsers_Orders_By_TotalPlays_Descending() + { + var lightUserName = $"user-{Guid.NewGuid():N}"; + var heavyUserName = $"user-{Guid.NewGuid():N}"; + + await using (var context = fixture.CreateContext()) + { + var lightUser = User.Create(UniqueUserId(), lightUserName, "Light"); + var heavyUser = User.Create(UniqueUserId(), heavyUserName, "Heavy"); + context.Users.AddRange(lightUser, heavyUser); + + var lightSong = Song.Create($"https://youtube.com/watch?v={Guid.NewGuid():N}", "Light Song"); + var heavySong = Song.Create($"https://youtube.com/watch?v={Guid.NewGuid():N}", "Heavy Song"); + context.Songs.AddRange(lightSong, heavySong); + await context.SaveChangesAsync(); + + context.PlayHistory.Add(PlayHistory.Create(DateTimeOffset.UtcNow, lightUser.Id, lightSong.Id)); + + var heavyHistory = PlayHistory.Create(DateTimeOffset.UtcNow, heavyUser.Id, heavySong.Id); + PlayHistory.UpdateTotalPlays(heavyHistory); + PlayHistory.UpdateTotalPlays(heavyHistory); + context.PlayHistory.Add(heavyHistory); + + await context.SaveChangesAsync(); + } + + await using (var freshContext = fixture.CreateContext()) + { + var service = new UserService(freshContext); + var users = (await service.GetAllUsersAsync()).ToList(); + + var heavyIndex = users.FindIndex(u => u.Username == heavyUserName); + var lightIndex = users.FindIndex(u => u.Username == lightUserName); + + Assert.True(heavyIndex >= 0); + Assert.True(lightIndex >= 0); + Assert.True(heavyIndex < lightIndex, + "User with more plays should be ordered before user with fewer plays."); + + Assert.Equal(3, users[heavyIndex].TotalPlays); + Assert.NotNull(users[heavyIndex].LastPlayed); + } + } +} diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj new file mode 100644 index 0000000..d37e792 --- /dev/null +++ b/src/Tests/Tests.csproj @@ -0,0 +1,24 @@ + + + + false + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/src/Tests/Unit/EventingTests.cs b/src/Tests/Unit/EventingTests.cs new file mode 100644 index 0000000..ddb136f --- /dev/null +++ b/src/Tests/Unit/EventingTests.cs @@ -0,0 +1,64 @@ +using Application.Eventing; +using Domain.Common; +using Domain.Eventing; +using Domain.Events; +using Infrastructure.Services; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Tests.Unit; + +public class EventingTests +{ + [Fact] + public void AddEventing_Registers_PlayerHandler_As_Sync_Handler_For_Skip_And_Stop() + { + var services = new ServiceCollection(); + services.AddEventing( + typeof(Application.AssemblyMarker).Assembly, + typeof(Infrastructure.Services.AssemblyMarker).Assembly); + + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService(); + + Assert.Contains(typeof(PlayerHandler), registry.GetSyncHandlers(typeof(EventType.Skip))); + Assert.Contains(typeof(PlayerHandler), registry.GetSyncHandlers(typeof(EventType.Stop))); + + // The Play event is no longer handled; enqueueing wakes the background consumer directly. + Assert.Empty(registry.GetSyncHandlers(typeof(EventType.Play))); + Assert.Empty(registry.GetAsyncHandlers(typeof(EventType.Play))); + } + + [Fact] + public void EventDispatcher_Dispatch_Invokes_Registered_Sync_Handler() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddEventing(typeof(EventingTests).Assembly); + + using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + + var dispatcher = scope.ServiceProvider.GetRequiredService(); + dispatcher.Dispatch(new TestEvent()); + dispatcher.Dispatch(new TestEvent()); + + var recorder = provider.GetRequiredService(); + Assert.Equal(2, recorder.Count); + } +} + +public sealed record TestEvent : IEvent; + +public sealed class EventRecorder +{ + public int Count; +} + +public sealed class TestEventHandler(EventRecorder recorder) : IEventHandler +{ + public void Handle(TestEvent @event) + { + Interlocked.Increment(ref recorder.Count); + } +} diff --git a/src/Tests/Unit/MusicPlayerBackgroundServiceTests.cs b/src/Tests/Unit/MusicPlayerBackgroundServiceTests.cs new file mode 100644 index 0000000..a5ef9b8 --- /dev/null +++ b/src/Tests/Unit/MusicPlayerBackgroundServiceTests.cs @@ -0,0 +1,295 @@ +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 MusicPlayerBackgroundServiceTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private static PlayRequest CreateRequest(List? messages = null) + { + return new PlayRequest + { + Callbacks = message => + { + if (messages is not null) + { + lock (messages) + { + messages.Add(message); + } + } + + return Task.CompletedTask; + } + }; + } + + private static MusicPlayerBackgroundService CreateService( + IMusicQueueService queue, INetCordAudioPlayerService audioPlayer) + { + return new MusicPlayerBackgroundService(queue, audioPlayer, + NullLogger.Instance); + } + + 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 Plays_Queued_Tracks_Sequentially_In_Order() + { + var queue = new MusicQueueService(); + var audioPlayer = Substitute.For(); + var played = new List(); + audioPlayer.PlayTrackAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + lock (played) + { + played.Add(callInfo.Arg().Id); + } + + return TrackPlayResult.Completed; + }); + + var first = CreateRequest(); + var second = CreateRequest(); + queue.Enqueue(first); + queue.Enqueue(second); + + var service = CreateService(queue, audioPlayer); + await service.StartAsync(CancellationToken.None); + try + { + await WaitUntilAsync(() => + { + lock (played) + { + return played.Count == 2; + } + }, "both tracks played"); + + Assert.Equal([first.Id, second.Id], played); + Assert.Null(queue.NowPlaying); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task Failed_Track_Is_Retried_Up_To_Three_Attempts_Then_Dropped() + { + var queue = new MusicQueueService(); + var audioPlayer = Substitute.For(); + var attempts = 0; + audioPlayer.PlayTrackAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + Interlocked.Increment(ref attempts); + return TrackPlayResult.Failed; + }); + + queue.Enqueue(CreateRequest()); + + var service = CreateService(queue, audioPlayer); + await service.StartAsync(CancellationToken.None); + try + { + await WaitUntilAsync(() => Volatile.Read(ref attempts) == 3, "three play attempts"); + await WaitUntilAsync(() => queue.NowPlaying is null, "track dropped"); + + // Give the loop a moment to prove it does not retry a fourth time. + await Task.Delay(200); + Assert.Equal(3, Volatile.Read(ref attempts)); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task Skip_Cancels_Current_Track_And_Advances_To_Next() + { + var queue = new MusicQueueService(); + var audioPlayer = Substitute.For(); + var firstStarted = new TaskCompletionSource(); + var playedSecond = new TaskCompletionSource(); + var first = CreateRequest(); + var second = CreateRequest(); + + audioPlayer.PlayTrackAsync(Arg.Any(), Arg.Any()) + .Returns(async callInfo => + { + var request = callInfo.Arg(); + var token = callInfo.Arg(); + if (request.Id == first.Id) + { + firstStarted.TrySetResult(); + // Simulate a long track that only ends when cancelled. + var cancelled = new TaskCompletionSource(); + await using var registration = token.Register(() => cancelled.TrySetResult()); + await cancelled.Task.WaitAsync(Timeout); + return TrackPlayResult.Skipped; + } + + playedSecond.TrySetResult(); + return TrackPlayResult.Completed; + }); + + queue.Enqueue(first); + queue.Enqueue(second); + + var service = CreateService(queue, audioPlayer); + await service.StartAsync(CancellationToken.None); + try + { + await firstStarted.Task.WaitAsync(Timeout); + + service.Skip(); + + await playedSecond.Task.WaitAsync(Timeout); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task Stop_Clears_Pending_Cancels_Current_And_Disconnects() + { + var queue = new MusicQueueService(); + var audioPlayer = Substitute.For(); + var firstStarted = new TaskCompletionSource(); + var disconnected = new TaskCompletionSource(); + var first = CreateRequest(); + var second = CreateRequest(); + + audioPlayer.PlayTrackAsync(Arg.Any(), Arg.Any()) + .Returns(async callInfo => + { + var token = callInfo.Arg(); + firstStarted.TrySetResult(); + var cancelled = new TaskCompletionSource(); + await using var registration = token.Register(() => cancelled.TrySetResult()); + await cancelled.Task.WaitAsync(Timeout); + return TrackPlayResult.Skipped; + }); + audioPlayer.DisconnectAsync().Returns(_ => + { + disconnected.TrySetResult(); + return Task.CompletedTask; + }); + + queue.Enqueue(first); + queue.Enqueue(second); + + var service = CreateService(queue, audioPlayer); + await service.StartAsync(CancellationToken.None); + try + { + await firstStarted.Task.WaitAsync(Timeout); + + service.Stop(); + + await disconnected.Task.WaitAsync(Timeout); + Assert.Equal(0, queue.Count); + + // Only the first track was ever played; the second was cleared. + await audioPlayer.Received(1).PlayTrackAsync(Arg.Any(), Arg.Any()); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task Disconnects_When_Queue_Empty_After_Last_Track() + { + var queue = new MusicQueueService(); + var audioPlayer = Substitute.For(); + var disconnected = new TaskCompletionSource(); + audioPlayer.PlayTrackAsync(Arg.Any(), Arg.Any()) + .Returns(TrackPlayResult.Completed); + audioPlayer.DisconnectAsync().Returns(_ => + { + disconnected.TrySetResult(); + return Task.CompletedTask; + }); + + var messages = new List(); + queue.Enqueue(CreateRequest(messages)); + + var service = CreateService(queue, audioPlayer); + await service.StartAsync(CancellationToken.None); + try + { + await disconnected.Task.WaitAsync(Timeout); + + await WaitUntilAsync(() => + { + lock (messages) + { + return messages.Count > 0; + } + }, "disconnect message sent"); + Assert.Contains("Disconnected from voice channel", messages[0]); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + } + + [Fact] + public async Task NotInVoiceChannel_Result_Invokes_Request_Callback_And_Does_Not_Retry() + { + var queue = new MusicQueueService(); + var audioPlayer = Substitute.For(); + audioPlayer.PlayTrackAsync(Arg.Any(), Arg.Any()) + .Returns(TrackPlayResult.NotInVoiceChannel); + + var messages = new List(); + queue.Enqueue(CreateRequest(messages)); + + var service = CreateService(queue, audioPlayer); + await service.StartAsync(CancellationToken.None); + try + { + await WaitUntilAsync(() => + { + lock (messages) + { + return messages.Any(m => m.Contains("not connected to any voice channel")); + } + }, "not-in-voice-channel message sent"); + + await audioPlayer.Received(1).PlayTrackAsync(Arg.Any(), Arg.Any()); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + } +} diff --git a/src/Tests/Unit/MusicQueueServiceTests.cs b/src/Tests/Unit/MusicQueueServiceTests.cs new file mode 100644 index 0000000..a1b026a --- /dev/null +++ b/src/Tests/Unit/MusicQueueServiceTests.cs @@ -0,0 +1,187 @@ +using Application.DTOs; +using Infrastructure.Services; +using NetCord.Services.ComponentInteractions; +using Xunit; + +namespace Tests.Unit; + +public class MusicQueueServiceTests +{ + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5); + + private static PlayRequest CreateRequest(string? url = null) + { + return new PlayRequest + { + Callbacks = _ => Task.CompletedTask, + VideoUrl = url + }; + } + + [Fact] + public async Task Enqueue_Then_Dequeue_Returns_Items_In_Fifo_Order() + { + var queue = new MusicQueueService(); + var first = CreateRequest(); + var second = CreateRequest(); + var third = CreateRequest(); + + queue.Enqueue(first); + queue.Enqueue(second); + queue.Enqueue(third); + + Assert.Equal(first.Id, (await queue.DequeueAsync(CancellationToken.None)).Id); + Assert.Equal(second.Id, (await queue.DequeueAsync(CancellationToken.None)).Id); + Assert.Equal(third.Id, (await queue.DequeueAsync(CancellationToken.None)).Id); + Assert.Equal(0, queue.Count); + } + + [Fact] + public async Task Dequeue_Waits_Until_Item_Is_Enqueued() + { + var queue = new MusicQueueService(); + + var dequeueTask = queue.DequeueAsync(CancellationToken.None).AsTask(); + await Task.Delay(100); + Assert.False(dequeueTask.IsCompleted); + + var request = CreateRequest(); + queue.Enqueue(request); + + var dequeued = await dequeueTask.WaitAsync(Timeout); + Assert.Equal(request.Id, dequeued.Id); + } + + [Fact] + public async Task Dequeue_Honors_Cancellation() + { + var queue = new MusicQueueService(); + using var cts = new CancellationTokenSource(); + + var dequeueTask = queue.DequeueAsync(cts.Token).AsTask(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(() => dequeueTask.WaitAsync(Timeout)); + } + + [Fact] + public async Task Clear_Removes_Pending_Items_And_Stale_Signals_Do_Not_Yield_Items() + { + var queue = new MusicQueueService(); + queue.Enqueue(CreateRequest()); + queue.Enqueue(CreateRequest()); + + queue.Clear(); + Assert.Equal(0, queue.Count); + + // The two stale signals left behind by Clear must not produce items. + var afterClear = CreateRequest(); + queue.Enqueue(afterClear); + + var dequeued = await queue.DequeueAsync(CancellationToken.None) + .AsTask().WaitAsync(Timeout); + Assert.Equal(afterClear.Id, dequeued.Id); + + // Nothing is left: the next dequeue must block even though stale signals may remain. + var blocked = queue.DequeueAsync(CancellationToken.None).AsTask(); + await Task.Delay(100); + Assert.False(blocked.IsCompleted); + } + + [Fact] + public void Count_Excludes_NowPlaying_And_GetAllRequests_Puts_NowPlaying_First() + { + var queue = new MusicQueueService(); + var playing = CreateRequest(); + var pending = CreateRequest(); + + queue.SetNowPlaying(playing); + queue.Enqueue(pending); + + Assert.Equal(1, queue.Count); + Assert.Equal(playing.Id, queue.NowPlaying?.Id); + + var all = queue.GetAllRequests(); + Assert.Equal(2, all.Length); + Assert.Equal(playing.Id, all[0].Id); + Assert.Equal(pending.Id, all[1].Id); + + queue.SetNowPlaying(null); + Assert.Null(queue.NowPlaying); + Assert.Single(queue.GetAllRequests()); + } + + [Fact] + public async Task Rewind_Inserts_NowPlaying_At_Front_And_Signals() + { + var queue = new MusicQueueService(); + var playing = CreateRequest(); + playing.RetryCount = 2; + var pending = CreateRequest(); + + queue.SetNowPlaying(playing); + queue.Enqueue(pending); + + queue.Rewind(); + + // The rewound track is in front of the previously pending one, with retries reset. + var first = await queue.DequeueAsync(CancellationToken.None) + .AsTask().WaitAsync(Timeout); + Assert.Equal(playing.Id, first.Id); + Assert.Equal(0, first.RetryCount); + + var second = await queue.DequeueAsync(CancellationToken.None) + .AsTask().WaitAsync(Timeout); + Assert.Equal(pending.Id, second.Id); + } + + [Fact] + public void Rewind_Without_NowPlaying_Is_A_NoOp() + { + var queue = new MusicQueueService(); + queue.Rewind(); + Assert.Equal(0, queue.Count); + } + + [Fact] + public async Task Concurrent_Enqueues_Are_All_Dequeued_Exactly_Once() + { + var queue = new MusicQueueService(); + const int producers = 4; + const int itemsPerProducer = 25; + const int total = producers * itemsPerProducer; + + var produced = new List(); + var producerTasks = Enumerable.Range(0, producers).Select(_ => Task.Run(() => + { + for (var i = 0; i < itemsPerProducer; i++) + { + var request = CreateRequest(); + lock (produced) + { + produced.Add(request.Id); + } + + queue.Enqueue(request); + } + })).ToArray(); + + var consumed = new List(); + var consumerTask = Task.Run(async () => + { + for (var i = 0; i < total; i++) + { + var item = await queue.DequeueAsync(CancellationToken.None); + consumed.Add(item.Id); + } + }); + + await Task.WhenAll(producerTasks).WaitAsync(Timeout); + await consumerTask.WaitAsync(Timeout); + + Assert.Equal(total, consumed.Count); + Assert.Equal(consumed.Count, consumed.Distinct().Count()); + Assert.Equal(produced.OrderBy(id => id), consumed.OrderBy(id => id)); + Assert.Equal(0, queue.Count); + } +} diff --git a/src/Worker/DependencyInjection.cs b/src/Worker/DependencyInjection.cs index 3793f30..caca365 100644 --- a/src/Worker/DependencyInjection.cs +++ b/src/Worker/DependencyInjection.cs @@ -1,14 +1,13 @@ using Application.Configs; +using Application.Eventing; using Application.Interfaces.Services; using Application.Services; using Application.Store; -using Domain.Common; using Infrastructure.Commands; using Infrastructure.Interaction; using Infrastructure.Services; using NetCord; using NetCord.Gateway; -using NetCord.Gateway.Voice; using NetCord.Hosting.Gateway; using NetCord.Hosting.Services.ApplicationCommands; using NetCord.Hosting.Services.ComponentInteractions; @@ -45,14 +44,21 @@ public static void AddDiscordServices(this IServiceCollection services, IConfigu services.AddLogging(opts => opts.AddConsole()); + services.AddHttpClient(); + services.AddSingleton(); - 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()); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Worker/appsettings.json b/src/Worker/appsettings.json index 3b8917f..d082899 100644 --- a/src/Worker/appsettings.json +++ b/src/Worker/appsettings.json @@ -14,6 +14,9 @@ "Token": "INJECTED_FROM_KEY_VAULT", "Prefix": "/" }, + "Ffmpeg": { + "Path": "/usr/bin/ffmpeg" + }, "WebsiteSettings": { "Url": "INJECTED_FROM_KEY_VAULT" },