using Microsoft.Extensions.Logging; using SameBeat.Application.DTO; using SameBeat.Application.Interfejsy; using SameBeat.Domain.Encje; using SameBeat.Domain.Enumy; namespace SameBeat.Application.Serwisy; /// /// Logika mikrofonu (ETAP 5) -- NAJWAZNIEJSZA funkcja produktu. Host przyznaje uczestnikowi na scenie /// mikrofon na dokladnie okreslony czas; serwer (nie przegladarka) autorytatywnie ten czas egzekwuje /// w dwoch warstwach jednoczesnie: /// 1. baza (Wydarzenie.AktywnyMikrofonKoniecUtc) -- SerwisTimeraMikrofonu odpytuje ja co ok. 1s, /// 2. LiveKit Room Service API (ISerwisAdministracjiLiveKit) -- FIZYCZNIE odbiera prawo publikacji /// audio z aktywnego polaczenia uczestnika, zeby "koniec w bazie" nie byl tylko teoria. /// /// Jeden aktywny mikrofon na raz w ramach wydarzenia -- prostsze i bezpieczniejsze niz automatyczne /// przerywanie poprzedniego; host musi swiadomie kliknac "przerwij", zanim przyzna komus innemu. /// public sealed class SerwisMikrofonu : ISerwisMikrofonu { private const string BladBrakWydarzenia = "Brak aktywnego wydarzenia, sprobuj pozniej."; private readonly IRepozytoriumWydarzen _repozytoriumWydarzen; private readonly IRepozytoriumPoczekalni _repozytoriumPoczekalni; private readonly IRepozytoriumSceny _repozytoriumSceny; private readonly IRepozytoriumHistoriiMikrofonu _repozytoriumHistoriiMikrofonu; private readonly ISerwisAdministracjiLiveKit _serwisAdministracjiLiveKit; private readonly IJednostkaPracy _jednostkaPracy; private readonly string _nazwaPokojuGlowna; private readonly ILogger _logger; public SerwisMikrofonu( IRepozytoriumWydarzen repozytoriumWydarzen, IRepozytoriumPoczekalni repozytoriumPoczekalni, IRepozytoriumSceny repozytoriumSceny, IRepozytoriumHistoriiMikrofonu repozytoriumHistoriiMikrofonu, ISerwisAdministracjiLiveKit serwisAdministracjiLiveKit, IJednostkaPracy jednostkaPracy, string nazwaPokojuGlowna, ILogger logger) { _repozytoriumWydarzen = repozytoriumWydarzen; _repozytoriumPoczekalni = repozytoriumPoczekalni; _repozytoriumSceny = repozytoriumSceny; _repozytoriumHistoriiMikrofonu = repozytoriumHistoriiMikrofonu; _serwisAdministracjiLiveKit = serwisAdministracjiLiveKit; _jednostkaPracy = jednostkaPracy; _nazwaPokojuGlowna = nazwaPokojuGlowna; _logger = logger; } public async Task> PobierzStanAsync(CancellationToken token = default) { var wydarzenie = await _repozytoriumWydarzen.PobierzOstatnieAsync(token); if (wydarzenie is null) { // Brak wydarzenia to normalny stan (host jeszcze niczego nie utworzyl) -- pusty stan, nie blad. return WynikOperacji.Ok(new StanMikrofonuDto(null, null, null)); } return WynikOperacji.Ok(await BudowniczyStanuMikrofonu.ZbudujAsync(wydarzenie, _repozytoriumPoczekalni, _repozytoriumSceny, token)); } public async Task> PrzyznajAsync(Guid uczestnikId, int czasSekundy, string przyznalHostNick, CancellationToken token = default) { // Gorny limit (5 minut) to zabezpieczenie przed pomylka hosta (np. literowka przy 5 -> 500) // -- panel UI oferuje tylko 5/10/15s, ale endpoint jest wywolywany z liczba wprost, wiec // warto pilnowac tego tez po stronie serwera. const int MaksymalnyCzasSekundy = 300; if (czasSekundy is <= 0 or > MaksymalnyCzasSekundy) { return WynikOperacji.Niepowodzenie($"Czas przyznania mikrofonu musi byc miedzy 1 a {MaksymalnyCzasSekundy} sekund."); } var wydarzenie = await _repozytoriumWydarzen.PobierzOstatnieAsync(token); if (wydarzenie is null) { return WynikOperacji.Niepowodzenie(BladBrakWydarzenia); } var uczestnik = await _repozytoriumPoczekalni.ZnajdzPoIdAsync(uczestnikId, token); if (uczestnik is null || uczestnik.WydarzenieId != wydarzenie.Id) { return WynikOperacji.Niepowodzenie("Nie znaleziono uczestnika o podanym identyfikatorze."); } // Mikrofon ma sens wylacznie dla kogos, kogo widac i slychac na scenie -- nie da sie przyznac // mikrofonu komus, kto siedzi jeszcze w poczekalni (nie ma nawet polaczenia do pokoju glownego). if (uczestnik.Status != StatusUczestnikaOczekujacego.NaScenie) { return WynikOperacji.Niepowodzenie( $"Mikrofon mozna przyznac tylko uczestnikowi, ktory jest na scenie (aktualny status: {uczestnik.Status})."); } if (wydarzenie.AktywnyMikrofonUczestnikId is not null) { return WynikOperacji.Niepowodzenie( "Mikrofon jest juz przyznany innemu uczestnikowi -- najpierw go przerwij."); } wydarzenie.PrzyznajMikrofon(uczestnikId, czasSekundy); var wpisHistorii = HistoriaMikrofonu.Rozpocznij(wydarzenie.Id, uczestnikId, czasSekundy, przyznalHostNick); _repozytoriumHistoriiMikrofonu.Dodaj(wpisHistorii); await _jednostkaPracy.ZapiszZmianyAsync(token); _logger.LogInformation("Mikrofon przyznany uczestnikowi {UczestnikId} na {CzasSekundy}s przez hosta {HostNick}.", uczestnikId, czasSekundy, przyznalHostNick); // Best effort -- FIZYCZNIE dopisuje "microphone" do canPublishSources aktywnego polaczenia. // Gdy LiveKit jest niedostepny, ISerwisAdministracjiLiveKit sam loguje i polyka blad (patrz // komentarz w jego implementacji) -- stan w bazie juz jest poprawny niezaleznie od tego wywolania. await _serwisAdministracjiLiveKit.UstawUprawnieniaMikrofonuAsync(uczestnik.LiveKitIdentity, _nazwaPokojuGlowna, mikrofonWlaczony: true, token); var slot = await _repozytoriumSceny.ZnajdzPoUczestnikuAsync(wydarzenie.Id, uczestnikId, token); return WynikOperacji.Ok(new StanMikrofonuDto(slot?.NumerSlotu, uczestnik.Nick, wydarzenie.AktywnyMikrofonKoniecUtc)); } public async Task> PrzerwijAsync(Guid uczestnikId, CancellationToken token = default) { var wydarzenie = await _repozytoriumWydarzen.PobierzOstatnieAsync(token); if (wydarzenie is null) { return WynikOperacji.Niepowodzenie(BladBrakWydarzenia); } // Idempotentne, tak samo jak ZwolnijSlotAsync w SerwisSceny: host mogl kliknac "przerwij" dwa // razy, albo timer wlasnie odebral mikrofon milisekundy wczesniej -- to sukces bez zmian, nie blad. if (wydarzenie.AktywnyMikrofonUczestnikId != uczestnikId) { return WynikOperacji.Ok(await BudowniczyStanuMikrofonu.ZbudujAsync(wydarzenie, _repozytoriumPoczekalni, _repozytoriumSceny, token)); } var dto = await OdbierzWewnetrznieAsync(wydarzenie, uczestnikId, token); _logger.LogInformation("Mikrofon uczestnika {UczestnikId} przerwany recznie przez hosta.", uczestnikId); return WynikOperacji.Ok(dto); } public async Task SprawdzIOdbierzWygasleAsync(CancellationToken token = default) { var wydarzenie = await _repozytoriumWydarzen.PobierzOstatnieAsync(token); if (wydarzenie?.AktywnyMikrofonUczestnikId is not Guid uczestnikId) { return null; } if (wydarzenie.AktywnyMikrofonKoniecUtc is null || wydarzenie.AktywnyMikrofonKoniecUtc > DateTime.UtcNow) { return null; } var dto = await OdbierzWewnetrznieAsync(wydarzenie, uczestnikId, token); _logger.LogInformation("Mikrofon uczestnika {UczestnikId} odebrany automatycznie -- czas uplynal.", uczestnikId); return dto; } /// /// Wspolna logika odbierania -- uzywana zarowno przez PrzerwijAsync (host klika recznie), jak i /// SprawdzIOdbierzWygasleAsync (autorytatywny timer po stronie serwera). To jest KRYTYCZNY punkt /// z punktu widzenia bezpieczenstwa: nie wystarczy sam zapis do bazy (patrz komentarz w /// ISerwisAdministracjiLiveKit) -- wolamy tez LiveKit, zeby fizycznie zamknac publikacje audio. /// private async Task OdbierzWewnetrznieAsync(Wydarzenie wydarzenie, Guid uczestnikId, CancellationToken token) { var uczestnik = await _repozytoriumPoczekalni.ZnajdzPoIdAsync(uczestnikId, token); var slot = await _repozytoriumSceny.ZnajdzPoUczestnikuAsync(wydarzenie.Id, uczestnikId, token); var otwartyWpisHistorii = await _repozytoriumHistoriiMikrofonu.ZnajdzOtwartyAsync(wydarzenie.Id, uczestnikId, token); otwartyWpisHistorii?.Zakoncz(); wydarzenie.OdbierzMikrofon(); await _jednostkaPracy.ZapiszZmianyAsync(token); if (uczestnik is not null) { await _serwisAdministracjiLiveKit.UstawUprawnieniaMikrofonuAsync(uczestnik.LiveKitIdentity, _nazwaPokojuGlowna, mikrofonWlaczony: false, token); } // KoniecUtc=null -- mikrofon juz nie jest aktywny, wiec "kiedy sie konczy" przestaje miec sens. return new StanMikrofonuDto(slot?.NumerSlotu, uczestnik?.Nick, null); } }