diff --git a/README.md b/README.md index 0e2463b..2bfb52b 100644 --- a/README.md +++ b/README.md @@ -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
(IGuildMusicService)
one GuildPlayer per guild"] + + subgraph PA["GuildPlayer - Server A"] + QA["MusicQueueService
(list + channel signal)"] + LA["Consumer loop
(retry x3, per-track cancellation)"] + AA["AudioPlayerService
(voice client A)"] + FA["FfmpegProcessService
(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
(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
yt-dlp / YoutubeExplode /
SoundCloud / radio URL"] + DB[("PostgreSQL
stats + blacklist
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
Server A")) + FB -->|"PCM to Opus stream"| VB(("Voice channel
Server B")) +``` + ## Technologies Used ### Backend diff --git a/src/Application/DTOs/Stats/RecentSongDto.cs b/src/Application/DTOs/Stats/RecentSongDto.cs new file mode 100644 index 0000000..64e2caa --- /dev/null +++ b/src/Application/DTOs/Stats/RecentSongDto.cs @@ -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; } +} diff --git a/src/Application/DTOs/Stats/UserStatsDto.cs b/src/Application/DTOs/Stats/UserStatsDto.cs index 69151f3..6ffbb61 100644 --- a/src/Application/DTOs/Stats/UserStatsDto.cs +++ b/src/Application/DTOs/Stats/UserStatsDto.cs @@ -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 RecentSongs { get; init; } = []; } \ No newline at end of file diff --git a/src/Infrastructure/Services/UserService.cs b/src/Infrastructure/Services/UserService.cs index cba441d..47f5746 100644 --- a/src/Infrastructure/Services/UserService.cs +++ b/src/Infrastructure/Services/UserService.cs @@ -37,7 +37,17 @@ public async Task> 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(); diff --git a/src/UI/Api/ControllerExtensions.cs b/src/UI/Api/ControllerExtensions.cs index 6018d42..ebf5d70 100644 --- a/src/UI/Api/ControllerExtensions.cs +++ b/src/UI/Api/ControllerExtensions.cs @@ -31,9 +31,20 @@ 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"); @@ -41,8 +52,15 @@ await radioSourceService.UpdateRadioSourceUrlAsync(id, request.Name, request.New 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"); @@ -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"); @@ -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("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."); } @@ -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() diff --git a/src/UI/Api/DependencyResolver.cs b/src/UI/Api/DependencyResolver.cs index b62be33..e9a69b2 100644 --- a/src/UI/Api/DependencyResolver.cs +++ b/src/UI/Api/DependencyResolver.cs @@ -29,6 +29,8 @@ public static void AddApiDependencies(this IServiceCollection services, IConfigu "JwtSettings:Secret is not configured"))), ValidIssuer = configuration.GetValue("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 diff --git a/src/UI/App/src/context/AuthContext.tsx b/src/UI/App/src/context/AuthContext.tsx index ca19541..7dd19c2 100644 --- a/src/UI/App/src/context/AuthContext.tsx +++ b/src/UI/App/src/context/AuthContext.tsx @@ -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); @@ -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 { diff --git a/src/UI/App/src/interfaces/common.interfaces.ts b/src/UI/App/src/interfaces/common.interfaces.ts index 0335fbf..46165a4 100644 --- a/src/UI/App/src/interfaces/common.interfaces.ts +++ b/src/UI/App/src/interfaces/common.interfaces.ts @@ -1,3 +1,9 @@ +export interface RecentSong { + title: string; + totalPlays: number; + playedAt: string; +} + export interface UserStatsDto { username: string; totalPlays: number; @@ -5,11 +11,12 @@ export interface UserStatsDto { memberSince: Date; lastPlayed?: Date | null; displayName?: string | null; + recentSongs: RecentSong[]; } export interface SongStat { title: string; - artist: string; + artist?: string | null; playCount: number; lastPlayed: Date; } diff --git a/src/UI/App/src/pages/Login.tsx b/src/UI/App/src/pages/Login.tsx index e842e1d..287cdff 100644 --- a/src/UI/App/src/pages/Login.tsx +++ b/src/UI/App/src/pages/Login.tsx @@ -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.'); } @@ -25,7 +25,7 @@ export function Login() { } return ( -
e.target === e.currentTarget}> +

Login

diff --git a/src/UI/App/src/pages/RadioAdmin.tsx b/src/UI/App/src/pages/RadioAdmin.tsx index f72f6ba..260225b 100644 --- a/src/UI/App/src/pages/RadioAdmin.tsx +++ b/src/UI/App/src/pages/RadioAdmin.tsx @@ -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, @@ -17,6 +19,8 @@ export function RadioAdmin() { null, ); const queryClient = useQueryClient(); + const navigate = useNavigate(); + const { logout } = useAuth(); const { data: radioStations, @@ -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: () => { @@ -74,6 +88,7 @@ export function RadioAdmin() { }); if (isLoading) return ; + if (unauthorized) return null; if (error) return ; if (!radioStations) return null; diff --git a/src/UI/App/src/pages/UserStats.tsx b/src/UI/App/src/pages/UserStats.tsx index 2fbf924..1bd8e33 100644 --- a/src/UI/App/src/pages/UserStats.tsx +++ b/src/UI/App/src/pages/UserStats.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { Fragment, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { PieChart, @@ -9,6 +9,7 @@ import { Tooltip, } from 'recharts'; import { loadUsers } from '../services/user-service'; +import { cleanTitle } from '../services/song-stats-service'; import { LoadingSpinner } from '../components/LoadingSpinner'; import { AppError } from '../components/AppError'; @@ -25,6 +26,7 @@ const COLORS = [ export function UserStats() { const [viewMode, setViewMode] = useState<'table' | 'chart'>('table'); + const [expandedUser, setExpandedUser] = useState(null); const { data: userStats, @@ -109,32 +111,82 @@ export function UserStats() { {userStats.slice(0, 10).map((user, index) => ( - - {index + 1} - -
-
- {user.username.slice(0, 2)} + + + setExpandedUser( + expandedUser === user.username ? null : user.username, + ) + } + style={{ cursor: 'pointer' }} + title="Click to see recently played songs" + > + {index + 1} + +
+
+ {user.username.slice(0, 2)} +
+
+
{user.username}
+
+ {user.displayName} +
+
-
-
{user.username}
-
#{user.displayName}
-
-
- - - {user.totalPlays} - - - {user.uniqueSongs} - - {String(user.memberSince).split('T')[0]} - - {user.lastPlayed - ? String(user.lastPlayed).split('T')[0] - : ''} - - + + + {user.totalPlays} + + + {user.uniqueSongs} + + {String(user.memberSince).split('T')[0]} + + {user.lastPlayed + ? String(user.lastPlayed).split('T')[0] + : ''} + + + {expandedUser === user.username && ( + + + {user.recentSongs.length === 0 ? ( + + No songs played yet. + + ) : ( + + + + + + + + + + {user.recentSongs.map((song, songIndex) => ( + + + + + + ))} + +
Recently PlayedPlaysLast Played
+
+ {cleanTitle(song.title)} +
+
+ + {song.totalPlays} + + {song.playedAt.split('T')[0]}
+ )} + + + )} + ))} diff --git a/src/UI/App/src/services/radio-source-service.ts b/src/UI/App/src/services/radio-source-service.ts index abaa57e..d1c3131 100644 --- a/src/UI/App/src/services/radio-source-service.ts +++ b/src/UI/App/src/services/radio-source-service.ts @@ -66,6 +66,12 @@ export async function addRadioSource( if (response.ok) { return await response.json(); } - const errorText = await response.json(); - throw new Error(JSON.stringify(errorText)); + let message = `HTTP error! status: ${response.status}`; + try { + const body = await response.json(); + message = body?.error ?? body?.detail ?? message; + } catch { + // Non-JSON error body (e.g. empty 401 response); keep the status message. + } + throw new Error(message); } diff --git a/src/UI/App/src/services/song-stats-service.ts b/src/UI/App/src/services/song-stats-service.ts index 6af1825..364798a 100644 --- a/src/UI/App/src/services/song-stats-service.ts +++ b/src/UI/App/src/services/song-stats-service.ts @@ -14,7 +14,8 @@ export async function loadSongStats(): Promise { return await response.json(); } -export function cleanTitle(title: string): string { +export function cleanTitle(title?: string | null): string { + if (!title) return ''; const normalized = removeFancyUnicode(title); return normalized .replace(/[\s|]*[([|]?\s*(official|lirik)[^)\]|]*[)\]|]?[\s|]*/gi, '')