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);
}
}