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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,57 @@ dotnet test src/Tests/Tests.csproj --filter "FullyQualifiedName~Tests.Unit"

Tests run automatically in CI (`.github/workflows/ci.yml`) on pushes and pull requests to `master`; pushes to `master` are deployed via `.github/workflows/release.yml`.

## Architecture

One bot process serves many servers. Commands address a guild through `IGuildMusicService`; `GuildPlayerManager` lazily creates one `GuildPlayer` per guild, each with its own queue, consumer loop, voice client, and FFmpeg process, so playback in one server never affects another. Statistics and the blacklist are shared across all servers.

```mermaid
flowchart TD
UA["User in Server A"] -->|"/play /skip /stop"| CMD
UB["User in Server B"] -->|"/play /skip /stop"| CMD

subgraph Worker["Worker process"]
CMD["Slash commands + NetCordInteraction"]
CMD -->|"Enqueue(guildId, PlayRequest)"| MGR
CMD -->|"EventType.Skip / Stop (GuildId)"| PH["EventDispatcher -> PlayerHandler"]
PH -->|"Skip(guildId) / Stop(guildId)"| MGR
MGR["GuildPlayerManager<br/>(IGuildMusicService)<br/>one GuildPlayer per guild"]

subgraph PA["GuildPlayer - Server A"]
QA["MusicQueueService<br/>(list + channel signal)"]
LA["Consumer loop<br/>(retry x3, per-track cancellation)"]
AA["AudioPlayerService<br/>(voice client A)"]
FA["FfmpegProcessService<br/>(ffmpeg process A)"]
QA -->|"signal wakes"| LA
LA -->|"PlayTrackAsync"| AA
AA -->|"spawn"| FA
end

subgraph PB["GuildPlayer - Server B"]
QB["MusicQueueService"]
LB["Consumer loop"]
AB["AudioPlayerService<br/>(voice client B)"]
FB["FfmpegProcessService"]
QB -->|"signal wakes"| LB
LB -->|"PlayTrackAsync"| AB
AB -->|"spawn"| FB
end

MGR -->|"guild A ops"| QA
MGR -->|"guild B ops"| QB

SS["Stream resolvers<br/>yt-dlp / YoutubeExplode /<br/>SoundCloud / radio URL"]
DB[("PostgreSQL<br/>stats + blacklist<br/>shared by all servers")]
AA -.->|"resolve stream URL"| SS
AB -.-> SS
AA -.->|"log play"| DB
AB -.-> DB
end

FA -->|"PCM to Opus stream"| VA(("Voice channel<br/>Server A"))
FB -->|"PCM to Opus stream"| VB(("Voice channel<br/>Server B"))
```

## Technologies Used

### Backend
Expand Down
8 changes: 8 additions & 0 deletions src/Application/DTOs/Stats/RecentSongDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Application.DTOs.Stats;

public class RecentSongDto
{
public string Title { get; init; } = string.Empty;
public int TotalPlays { get; init; }
public required DateTimeOffset PlayedAt { get; init; }
}
1 change: 1 addition & 0 deletions src/Application/DTOs/Stats/UserStatsDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ public class UserStatsDto
public int UniqueSongs { get; init; }
public required DateTimeOffset MemberSince { get; init; }
public DateTimeOffset? LastPlayed { get; set; } = null;
public IReadOnlyCollection<RecentSongDto> RecentSongs { get; init; } = [];
}
12 changes: 11 additions & 1 deletion src/Infrastructure/Services/UserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,17 @@ public async Task<ICollection<UserStatsDto>> GetAllUsersAsync()
.OrderByDescending(ph => ph.PlayedAt)
.Select(ph => (DateTimeOffset?)ph.PlayedAt)
.FirstOrDefault(),
DisplayName = u.DisplayName ?? u.Username
DisplayName = u.DisplayName ?? u.Username,
RecentSongs = u.PlayHistories
.OrderByDescending(ph => ph.PlayedAt)
.Take(10)
.Select(ph => new RecentSongDto
{
Title = ph.Song.Title,
TotalPlays = ph.TotalPlays,
PlayedAt = ph.PlayedAt
})
.ToList()
})
.OrderByDescending(u => u.TotalPlays)
.ToListAsync();
Expand Down
46 changes: 36 additions & 10 deletions src/UI/Api/ControllerExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,36 @@ await radioSourceService.GetAllRadioSourcesAsync(cancellationToken))
async (IRadioSourceService radioSourceService, Guid id, [FromBody] UpdateRadioSourceRequest request,
CancellationToken cancellationToken) =>
{
await radioSourceService.UpdateRadioSourceUrlAsync(id, request.Name, request.NewSourceUrl,
request.IsActive, cancellationToken);
return Results.NoContent();
try
{
await radioSourceService.UpdateRadioSourceUrlAsync(id, request.Name, request.NewSourceUrl,
request.IsActive, cancellationToken);
return Results.NoContent();
}
catch (KeyNotFoundException)
{
return Results.NotFound();
}
catch (ArgumentException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
})
.RequireAuthorization()
.WithName("UpdateRadioSourceUrl");

app.MapGet("/api/radio-sources/{id:guid}",
async (IRadioSourceService radioSourceService, Guid id, CancellationToken cancellationToken) =>
{
var radioSource = await radioSourceService.GetRadioSourceByIdAsync(id, cancellationToken);
return Results.Ok(radioSource);
try
{
var radioSource = await radioSourceService.GetRadioSourceByIdAsync(id, cancellationToken);
return Results.Ok(radioSource);
}
catch (KeyNotFoundException)
{
return Results.NotFound();
}
})
.RequireAuthorization()
.WithName("GetRadioSourceById");
Expand Down Expand Up @@ -74,8 +92,15 @@ await radioSourceService.UpdateRadioSourceUrlAsync(id, request.Name, request.New
app.MapDelete("/api/radio-sources/{id:guid}",
async (IRadioSourceService radioSourceService, Guid id, CancellationToken cancellationToken) =>
{
await radioSourceService.DeleteRadioSourceAsync(id, cancellationToken);
return Results.NoContent();
try
{
await radioSourceService.DeleteRadioSourceAsync(id, cancellationToken);
return Results.NoContent();
}
catch (KeyNotFoundException)
{
return Results.NotFound();
}
})
.RequireAuthorization()
.WithName("DeleteRadioSource");
Expand All @@ -89,7 +114,8 @@ await radioSourceService.UpdateRadioSourceUrlAsync(id, request.Name, request.New
var user = await userService.GetUserByUsernameAsync(request.Username);
// Note: In a real application, you would hash the password and compare it securely.
var password = configuration.GetValue<string>("JwtSettings:InternalPassword");
if (user == null || password != request.Password)
// Fail closed if the internal password is not configured or the request omits one.
if (string.IsNullOrEmpty(password) || user == null || password != request.Password)
{
throw new UnauthorizedAccessException("Invalid username or password.");
}
Expand All @@ -101,9 +127,9 @@ await radioSourceService.UpdateRadioSourceUrlAsync(id, request.Name, request.New
{
return Results.Unauthorized();
}
catch (Exception ex)
catch (Exception)
{
return Results.Problem(ex.Message);
return Results.Problem("An unexpected error occurred.");
}
})
.AllowAnonymous()
Expand Down
2 changes: 2 additions & 0 deletions src/UI/Api/DependencyResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public static void AddApiDependencies(this IServiceCollection services, IConfigu
"JwtSettings:Secret is not configured"))),
ValidIssuer = configuration.GetValue<string>("JwtSettings:Issuer") ??
throw new InvalidOperationException("JwtSettings:Issuer is not configured"),
// The token carries the raw "name" claim; without this, Identity.Name is null.
NameClaimType = "name",
};

options.Events = new JwtBearerEvents
Expand Down
7 changes: 6 additions & 1 deletion src/UI/App/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, type ReactNode } from 'react';
import { AuthContext } from '../hooks/useAuth';
import { API_BASE_URL } from '../services/api';

export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
Expand All @@ -11,12 +12,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return false;
}
try {
const response = await fetch('/api/auth/validate-token', {
const response = await fetch(`${API_BASE_URL}/auth/validate-token`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
// Token is expired or invalid; drop it so route guards fail closed too.
localStorage.removeItem('authToken');
}
setIsAuthenticated(response.ok);
return response.ok;
} catch {
Expand Down
9 changes: 8 additions & 1 deletion src/UI/App/src/interfaces/common.interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
export interface RecentSong {
title: string;
totalPlays: number;
playedAt: string;
}

export interface UserStatsDto {
username: string;
totalPlays: number;
uniqueSongs: number;
memberSince: Date;
lastPlayed?: Date | null;
displayName?: string | null;
recentSongs: RecentSong[];
}

export interface SongStat {
title: string;
artist: string;
artist?: string | null;
playCount: number;
lastPlayed: Date;
}
Expand Down
4 changes: 2 additions & 2 deletions src/UI/App/src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function Login() {
try {
const { token } = await loginUser(userName, password);
login(token);
navigate({ to: '/' });
navigate({ to: '/admin' });
} catch {
alert('Login failed. Please check your credentials.');
}
Expand All @@ -25,7 +25,7 @@ export function Login() {
}

return (
<div className="modal" onClick={(e) => e.target === e.currentTarget}>
<div className="modal">
<div className="modal-content">
<div className="modal-header">
<h2 className="modal-title">Login</h2>
Expand Down
17 changes: 16 additions & 1 deletion src/UI/App/src/pages/RadioAdmin.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useAuth } from '../hooks/useAuth';
import type { RadioSource } from '../interfaces/common.interfaces';
import {
loadRadioSources,
Expand All @@ -17,6 +19,8 @@ export function RadioAdmin() {
null,
);
const queryClient = useQueryClient();
const navigate = useNavigate();
const { logout } = useAuth();

const {
data: radioStations,
Expand All @@ -27,6 +31,16 @@ export function RadioAdmin() {
queryFn: loadRadioSources,
});

// The route guard only checks that a token exists; an expired one still
// reaches this page and fails with 401, so send the user back to login.
const unauthorized = error !== null && String(error).includes('401');
useEffect(() => {
if (unauthorized) {
logout();
navigate({ to: '/login' });
}
}, [unauthorized, logout, navigate]);

const deleteMutation = useMutation({
mutationFn: deleteRadioSource,
onSuccess: () => {
Expand Down Expand Up @@ -74,6 +88,7 @@ export function RadioAdmin() {
});

if (isLoading) return <LoadingSpinner />;
if (unauthorized) return null;
if (error) return <AppError message={String(error)} />;
if (!radioStations) return null;

Expand Down
Loading
Loading