using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; using SameBeat.Api.Monitoring; namespace SameBeat.Api.Hubs; /// /// Hub stanu imprezy -- kanal na zdarzenia sterujace (kto jest na scenie, czyj jest mikrofon, co gra /// w tle). WAZNE: obraz/wideo NIGDY nie idzie przez SignalR -- to wylacznie LiveKit (WebRTC). /// SignalR przenosi tylko metadane/zdarzenia sterujace, zeby nie dublowac transportu mediow. /// /// Szkielet na Etap 1 -- polaczenie i grupa "widzowie". ETAP 3 dodal pierwsze eventy sceny -- /// ScenaZmieniona, UczestnikDodanyDoSceny, UczestnikUsunietyZeSceny, UkladScenyZmieniony -- ale /// rozglaszane sa wprost przez Clients.All z HostStageController (payload zawsze StanScenyDto: /// numerSlotu+nick, NIGDY uczestnikId), nie przez metody tego hub'a. ETAP 4 dodal analogicznie /// eventy muzyki -- StanMuzykiZmieniony (payload StanMuzykiDto, po kazdej akcji zegara: play/pause/ /// next/poprzedni/glosnosc) i MuzykaZmieniona (payload List<PozycjaPlaylistyDto>, po zmianie /// biblioteki/kolejki) -- rozglaszane wprost przez Clients.All z HostMusicController, tak samo bez /// posrednictwa metod tego hub'a. ETAP 5 dodal eventy mikrofonu -- MikrofonPrzyznany i MikrofonOdebrany /// (payload zawsze StanMikrofonuDto: numerSlotu+nick+koniecUtc, NIGDY uczestnikId, ten sam kontrakt co /// scena) -- rozglaszane z HostMicrophoneController PRZY AKCJI HOSTA, ale takze -- to jest wyjatek od /// reguly "kontroler rozglasza" -- przy autorytatywnym WYGASNIECIU CZASU, ktore nie ma zadnego /// kontrolera: robi to SerwisTimeraMikrofonu (Infrastructure/Audio, BackgroundService dzialajacy w tle) /// przez waski adapter IRozgloszenieZdarzenImprezy (Api/Hubs/RozgloszenieZdarzenImprezy.cs), zeby /// Infrastructure nie musialo znac tej klasy Hub. ETAP 6 dodal eventy czatu -- ChatNowaWiadomosc /// (payload WiadomoscCzatuDto: id+nick+tresc+wyslanoUtc, NIGDY uczestnikCzatuId, ten sam kontrakt co /// scena/mikrofon) i ChatWiadomoscUsunieta (payload {id}) -- rozglaszane wprost przez Clients.All z /// ChatController/HostChatController, bez posrednictwa metod tego hub'a. W kolejnych etapach dojdzie /// tu jeszcze: UzytkownikWyrzucony, UzytkownikZbanowany. ETAP 8 dodal LicznikPolaczenSignalR (Api/Monitoring) /// -- prosty licznik w pamieci procesu, aktualizowany tutaj (OnConnectedAsync/OnDisconnectedAsync/ /// DolaczDoGrupyHosta), odczytywany przez GET /api/health (liczba ogolem) i GET /api/host/diagnostyka /// (rozbicie na "widzowie"/"host"). /// public class StanImprezyHub : Hub { private const string GrupaWidzowie = "widzowie"; /// Grupa panelu hosta -- odbiera zdarzenia sterujace typu "cos sie zmienilo w poczekalni". public const string GrupaHost = "host"; private readonly ILogger _logger; private readonly LicznikPolaczenSignalR _licznikPolaczen; public StanImprezyHub(ILogger logger, LicznikPolaczenSignalR licznikPolaczen) { _logger = logger; _licznikPolaczen = licznikPolaczen; } /// /// Dolacza biezace polaczenie do grupy "host", zeby dostawac zdarzenia panelu hosta /// (np. PoczekalniaZmieniona). /// /// ETAP 7: [Authorize] na poziomie metody -- SignalR sprawdza Context.User (ustalony raz, przy /// nawiazywaniu polaczenia/negocjacji, na podstawie DOKLADNIE tego samego mechanizmu co REST API: /// JwtBearerEvents.OnMessageReceived w Program.cs wyciaga token z ciasteczka httpOnly "sb_auth"). /// Ciasteczko jest wysylane przez przegladarke automatycznie przy negocjacji SignalR I przy /// upgrade'zie WebSocket (to zwykle zadania HTTP, nie inny mechanizm) -- warunkiem jest, zeby klient /// laczyl sie z { withCredentials: true } (frontend to juz robi, patrz SignalRSerwis.ts) i zeby /// origin frontendu byl dopuszczony w CORS z AllowCredentials (juz jest, patrz UstawieniaCors). /// Nie jest potrzebny token w query string (?access_token=...) -- to obejscie dotyczy tylko /// scenariusza "token w Authorization header", ktorego JS nie moze ustawic na natywnym WebSocket; /// przy auth przez ciasteczko przegladarka i tak dolacza je sama do kazdego zadania HTTP/WS. /// /// Gdy polaczajacy sie NIE jest zalogowanym hostem (brak/niewazne/wygasle ciasteczko), SignalR sam /// odrzuca to wywolanie -- klient dostaje HubException z invoke(), polaczenie NIE jest zrywane /// (nie jest przez to widzem odciety od grupy "widzowie"), po prostu nie trafia do grupy "host". /// /// ETAP 8: bezpieczne do wielokrotnego wywolania na tym samym polaczeniu -- Groups.AddToGroupAsync /// jest idempotentne (dodanie juz obecnego polaczenia do grupy nie jest bledem), a LicznikPolaczenSignalR /// nizej sledzi polaczenia w zbiorze (ConcurrentDictionary keyed po ConnectionId), wiec powtorne /// wywolanie (np. po automatycznym reconnect po stronie klienta, patrz komentarz w OnConnectedAsync) /// nie zawyza licznika. Frontend MUSI sam wywolac to ponownie po kazdym odtworzeniu polaczenia /// SignalR (np. w handlerze .onreconnected() klienta) -- serwer nie pamieta "byl hostem przed /// rozlaczeniem", bo kazde nowe polaczenie (rowniez reconnect) dostaje nowy ConnectionId i startuje /// wylacznie w grupie "widzowie", patrz OnConnectedAsync. /// [Authorize] public async Task DolaczDoGrupyHosta() { await Groups.AddToGroupAsync(Context.ConnectionId, GrupaHost); _licznikPolaczen.DolaczylDoGrupyHosta(Context.ConnectionId); } public override async Task OnConnectedAsync() { _licznikPolaczen.Polaczono(Context.ConnectionId); // OGRANICZENIE ETAP 8: SignalR na serwerze NIE rozroznia natywnie "nowego" polaczenia od // "reconnect" (automatyczny reconnect klienta z ETAPU 1 po prostu zamyka stare polaczenie i // otwiera nowe, z nowym ConnectionId -- z punktu widzenia OnConnectedAsync to zawsze wyglada // identycznie). Nie zgadujemy tego po iloĊ›ci/czasie -- log ponizej pokazuje wiec tylko fakt // nawiazania polaczenia i aktualna liczbe polaczen ogolem, bez etykiety "nowe"/"reconnect". _logger.LogInformation( "Polaczenie SignalR nawiazane: {ConnectionId}. Polaczen ogolem: {LiczbaPolaczen}.", Context.ConnectionId, _licznikPolaczen.LiczbaPolaczenOgolem); // Kazde nowe polaczenie domyslnie trafia do grupy widzow -- w kolejnych etapach przeniesienie // do innej grupy (np. "scena") bedzie nastepowac na podstawie roli po zalogowaniu. await Groups.AddToGroupAsync(Context.ConnectionId, GrupaWidzowie); await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception? wyjatek) { _licznikPolaczen.Rozlaczono(Context.ConnectionId); if (wyjatek is not null) { _logger.LogWarning(wyjatek, "Polaczenie SignalR rozlaczone z bledem: {ConnectionId}. Polaczen ogolem: {LiczbaPolaczen}.", Context.ConnectionId, _licznikPolaczen.LiczbaPolaczenOgolem); } else { _logger.LogInformation( "Polaczenie SignalR rozlaczone: {ConnectionId}. Polaczen ogolem: {LiczbaPolaczen}.", Context.ConnectionId, _licznikPolaczen.LiczbaPolaczenOgolem); } await base.OnDisconnectedAsync(wyjatek); } }