using Microsoft.Extensions.Logging;
using SameBeat.Application.DTO;
using SameBeat.Application.Interfejsy;
using SameBeat.Application.Komendy;
using SameBeat.Domain.Encje;
using SameBeat.Domain.Enumy;
namespace SameBeat.Application.Serwisy;
///
/// Logika sceny glownej (ETAP 3): uklad/sloty sterowane przez hosta oraz bezpieczne "wpuszczenie na
/// scene" uczestnika, ktory udowadnia tozsamosc WYLACZNIE sekretem sesji (nigdy samym id).
///
public sealed class SerwisSceny : ISerwisSceny
{
private readonly IRepozytoriumWydarzen _repozytoriumWydarzen;
private readonly IRepozytoriumPoczekalni _repozytoriumPoczekalni;
private readonly IRepozytoriumSceny _repozytoriumSceny;
private readonly ISerwisTokenowLiveKit _serwisTokenowLiveKit;
private readonly ISerwisAdministracjiLiveKit _serwisAdministracjiLiveKit;
private readonly IUslugaHaszowaniaSekretowSesji _uslugaHaszowaniaSekretowSesji;
private readonly IJednostkaPracy _jednostkaPracy;
private readonly string _urlLiveKit;
private readonly string _nazwaPokojuGlowna;
private readonly ILogger _logger;
public SerwisSceny(
IRepozytoriumWydarzen repozytoriumWydarzen,
IRepozytoriumPoczekalni repozytoriumPoczekalni,
IRepozytoriumSceny repozytoriumSceny,
ISerwisTokenowLiveKit serwisTokenowLiveKit,
ISerwisAdministracjiLiveKit serwisAdministracjiLiveKit,
IUslugaHaszowaniaSekretowSesji uslugaHaszowaniaSekretowSesji,
IJednostkaPracy jednostkaPracy,
string urlLiveKit,
string nazwaPokojuGlowna,
ILogger logger)
{
_repozytoriumWydarzen = repozytoriumWydarzen;
_repozytoriumPoczekalni = repozytoriumPoczekalni;
_repozytoriumSceny = repozytoriumSceny;
_serwisTokenowLiveKit = serwisTokenowLiveKit;
_serwisAdministracjiLiveKit = serwisAdministracjiLiveKit;
_uslugaHaszowaniaSekretowSesji = uslugaHaszowaniaSekretowSesji;
_jednostkaPracy = jednostkaPracy;
_urlLiveKit = urlLiveKit;
_nazwaPokojuGlowna = nazwaPokojuGlowna;
_logger = logger;
}
public async Task> PobierzStanScenyAsync(CancellationToken token = default)
{
var wydarzenie = await _repozytoriumWydarzen.PobierzOstatnieAsync(token);
if (wydarzenie is null)
{
// Brak wydarzenia to normalny stan (host jeszcze niczego nie utworzyl) -- pusta scena, nie blad.
return WynikOperacji.Ok(new StanScenyDto(UkladSceny.Grid4, new List()));
}
var sloty = await _repozytoriumSceny.PobierzZajeteZNickamiAsync(wydarzenie.Id, token);
return WynikOperacji.Ok(new StanScenyDto(wydarzenie.UkladSceny, sloty));
}
public async Task> ZmienUkladAsync(UkladSceny nowyUklad, CancellationToken token = default)
{
var wydarzenie = await _repozytoriumWydarzen.PobierzOstatnieAsync(token);
if (wydarzenie is null)
{
return WynikOperacji.Niepowodzenie("Brak aktywnego wydarzenia, sprobuj pozniej.");
}
wydarzenie.ZmienUklad(nowyUklad);
var liczbaSlotow = RejestrSlotowSceny.PobierzLiczbeSlotow(nowyUklad);
var zajeteSloty = await _repozytoriumSceny.PobierzZajeteDlaWydarzeniaAsync(wydarzenie.Id, token);
var uczestnicyUsunieciZeSceny = new List();
foreach (var slot in zajeteSloty.Where(s => s.NumerSlotu > liczbaSlotow))
{
// Sierocy slot po zmniejszeniu ukladu -- zwalniamy go, a jego uczestnik wraca do poczekalni.
var uczestnikUsuniety = await ZwolnijSlotWewnetrznieAsync(slot, token);
if (uczestnikUsuniety is not null)
{
uczestnicyUsunieciZeSceny.Add(uczestnikUsuniety);
}
}
await _jednostkaPracy.ZapiszZmianyAsync(token);
_logger.LogInformation("Uklad sceny wydarzenia {WydarzenieId} zmieniony na {Uklad}.", wydarzenie.Id, nowyUklad);
// Best effort, PO zapisie -- ETAP 7: to samo twarde wymuszenie co przy pojedynczym ZwolnijSlotAsync
// (patrz komentarz tam) -- sierocy slot po zmniejszeniu ukladu to dokladnie ten sam przypadek
// "uczestnik przestal byc na scenie", wiec musi tak samo fizycznie stracic canPublishSources.
foreach (var uczestnikUsuniety in uczestnicyUsunieciZeSceny)
{
await _serwisAdministracjiLiveKit.OdbierzUprawnieniaScenyAsync(uczestnikUsuniety.LiveKitIdentity, _nazwaPokojuGlowna, token);
}
var sloty = await _repozytoriumSceny.PobierzZajeteZNickamiAsync(wydarzenie.Id, token);
return WynikOperacji.Ok(new StanScenyDto(wydarzenie.UkladSceny, sloty));
}
public async Task> PrzypiszSlotAsync(int numerSlotu, Guid uczestnikId, CancellationToken token = default)
{
var wydarzenie = await _repozytoriumWydarzen.PobierzOstatnieAsync(token);
if (wydarzenie is null)
{
return WynikOperacji.Niepowodzenie("Brak aktywnego wydarzenia, sprobuj pozniej.");
}
if (!RejestrSlotowSceny.CzyNumerSlotuJestPoprawny(wydarzenie.UkladSceny, numerSlotu))
{
var maksSlotow = RejestrSlotowSceny.PobierzLiczbeSlotow(wydarzenie.UkladSceny);
return WynikOperacji.Niepowodzenie(
$"Numer slotu {numerSlotu} jest poza zakresem ukladu {wydarzenie.UkladSceny} (dostepne 1..{maksSlotow}).");
}
var uczestnik = await _repozytoriumPoczekalni.ZnajdzPoIdAsync(uczestnikId, token);
if (uczestnik is null)
{
return WynikOperacji.Niepowodzenie("Nie znaleziono uczestnika o podanym identyfikatorze.");
}
if (uczestnik.WydarzenieId != wydarzenie.Id)
{
return WynikOperacji.Niepowodzenie("Uczestnik nie nalezy do biezacego wydarzenia.");
}
var istniejacySlot = await _repozytoriumSceny.ZnajdzPoNumerzeAsync(wydarzenie.Id, numerSlotu, token);
if (istniejacySlot is not null && istniejacySlot.UczestnikOczekujacyId == uczestnikId)
{
// No-op: uczestnik juz zajmuje ten slot -- nic do zrobienia, ale to sukces, nie blad.
var slotyNoOp = await _repozytoriumSceny.PobierzZajeteZNickamiAsync(wydarzenie.Id, token);
return WynikOperacji.Ok(new StanScenyDto(wydarzenie.UkladSceny, slotyNoOp));
}
// Poza no-op wyzej, przypisac mozna tylko uczestnika, ktory realnie oczekuje w poczekalni --
// to jednoczesnie chroni przed "przeciaganiem" kogos juz-na-scenie z innego slotu (musialby
// najpierw zostac zdjety) i przed przypisaniem odrzuconego/zbanowanego.
if (uczestnik.Status != StatusUczestnikaOczekujacego.Oczekuje)
{
return WynikOperacji.Niepowodzenie(
$"Mozna wpuscic na scene tylko uczestnika ze statusem Oczekuje (aktualny status: {uczestnik.Status}).");
}
UczestnikOczekujacy? poprzedniUczestnik = null;
if (istniejacySlot is not null && istniejacySlot.UczestnikOczekujacyId is Guid poprzedniUczestnikId)
{
poprzedniUczestnik = await _repozytoriumPoczekalni.ZnajdzPoIdAsync(poprzedniUczestnikId, token);
poprzedniUczestnik?.ZdejmijZeSceny();
istniejacySlot.Zwolnij();
}
var slot = istniejacySlot ?? SlotSceny.Utworz(wydarzenie.Id, numerSlotu);
if (istniejacySlot is null)
{
_repozytoriumSceny.Dodaj(slot);
}
slot.Przypisz(uczestnikId);
uczestnik.WpuscNaScene();
await _jednostkaPracy.ZapiszZmianyAsync(token);
_logger.LogInformation("Uczestnik {UczestnikId} przypisany do slotu {NumerSlotu} wydarzenia {WydarzenieId}.",
uczestnikId, numerSlotu, wydarzenie.Id);
// ETAP 7: gdy slot byl juz zajety, "przeciagniecie" go na nowego uczestnika to dla poprzedniego
// dokladnie taki sam przypadek jak ZwolnijSlotAsync -- musi wiec tak samo twardo stracic
// canPublishSources w LiveKit, best effort, PO zapisie.
if (poprzedniUczestnik is not null)
{
await _serwisAdministracjiLiveKit.OdbierzUprawnieniaScenyAsync(poprzedniUczestnik.LiveKitIdentity, _nazwaPokojuGlowna, token);
}
var sloty = await _repozytoriumSceny.PobierzZajeteZNickamiAsync(wydarzenie.Id, token);
return WynikOperacji.Ok(new StanScenyDto(wydarzenie.UkladSceny, sloty));
}
public async Task> ZwolnijSlotAsync(int numerSlotu, CancellationToken token = default)
{
var wydarzenie = await _repozytoriumWydarzen.PobierzOstatnieAsync(token);
if (wydarzenie is null)
{
return WynikOperacji.Niepowodzenie("Brak aktywnego wydarzenia, sprobuj pozniej.");
}
var slot = await _repozytoriumSceny.ZnajdzPoNumerzeAsync(wydarzenie.Id, numerSlotu, token);
// Zwolnienie pustego/nieistniejacego slotu jest idempotentne -- to sukces, nie blad: host
// mogl kliknac "usun ze sceny" dwa razy albo klient mial nieaktualny stan.
//
// ETAP 7: oprocz "miekkiego" usuniecia w bazie (ponizej), TWARDO wymuszamy je tez w LiveKit --
// best effort, PO zapisie, zeby uczestnik fizycznie stracil canPublishSources (kamera+mikrofon)
// niezaleznie od tego, czy klient w ogole dostanie/obsluzy zdarzenie SignalR ponizej. Wczesniej
// (ETAPY 2-3) to bylo swiadomie odlozone -- klient MIAL OBOWIAZEK sam przestac publikowac.
if (slot is not null && slot.UczestnikOczekujacyId is Guid uczestnikId)
{
var uczestnikUsuniety = await ZwolnijSlotWewnetrznieAsync(slot, token);
await _jednostkaPracy.ZapiszZmianyAsync(token);
_logger.LogInformation("Slot {NumerSlotu} wydarzenia {WydarzenieId} zwolniony (uczestnik {UczestnikId} wraca do poczekalni).",
numerSlotu, wydarzenie.Id, uczestnikId);
if (uczestnikUsuniety is not null)
{
await _serwisAdministracjiLiveKit.OdbierzUprawnieniaScenyAsync(uczestnikUsuniety.LiveKitIdentity, _nazwaPokojuGlowna, token);
}
}
var sloty = await _repozytoriumSceny.PobierzZajeteZNickamiAsync(wydarzenie.Id, token);
return WynikOperacji.Ok(new StanScenyDto(wydarzenie.UkladSceny, sloty));
}
public async Task> PobierzStatusUczestnikaAsync(Guid uczestnikId, string sekret, CancellationToken token = default)
{
var (uczestnik, blad) = await ZweryfikujSekretAsync(uczestnikId, sekret, token);
if (uczestnik is null)
{
return WynikOperacji.Niepowodzenie(blad!);
}
// Wydarzenie trzyma ZYWY stan mikrofonu (AktywnyMikrofonUczestnikId) -- porownujemy z tym
// konkretnym uczestnikiem, zeby powiedziec mu (i TYLKO jemu, przez ten uwierzytelniony kanal)
// "to ty masz teraz glos", bez ujawniania tego publicznie.
var wydarzenie = await _repozytoriumWydarzen.ZnajdzPoIdAsync(uczestnik.WydarzenieId, token);
var mikrofonAktywny = wydarzenie is not null && wydarzenie.AktywnyMikrofonUczestnikId == uczestnik.Id;
return WynikOperacji.Ok(new StatusUczestnikaDto(
uczestnik.Status,
mikrofonAktywny,
mikrofonAktywny ? wydarzenie!.AktywnyMikrofonKoniecUtc : null));
}
public async Task> WydajTokenScenyAsync(TokenScenyKomenda komenda, CancellationToken token = default)
{
var (uczestnik, blad) = await ZweryfikujSekretAsync(komenda.UczestnikId, komenda.Sekret, token);
if (uczestnik is null)
{
return WynikOperacji.Niepowodzenie(blad!);
}
if (uczestnik.Status != StatusUczestnikaOczekujacego.NaScenie)
{
_logger.LogInformation("Odmowa tokenu sceny -- uczestnik {UczestnikId} nie jest jeszcze wpuszczony na scene (status: {Status}).",
uczestnik.Id, uczestnik.Status);
return WynikOperacji.Niepowodzenie("Nie zostales jeszcze wpuszczony na scene przez hosta.");
}
if (string.IsNullOrWhiteSpace(_urlLiveKit))
{
_logger.LogWarning("Token sceny odrzucony -- adres serwera LiveKit nie jest skonfigurowany.");
return WynikOperacji.Niepowodzenie("Usluga LiveKit nie jest jeszcze skonfigurowana na serwerze.");
}
// Token do pokoju GLOWNEGO (nie poczekalni!) -- to jest wlasnie moment "przejscia" z poczekalni
// na scene widoczna dla widzow. Rola Uczestnik: canPublish=true, ale wylacznie kamera -- tak
// samo jak w poczekalni, mikrofon nadal nigdy nie jest domyslnie wlaczony (dopiero ETAP 5).
var liveKitToken = _serwisTokenowLiveKit.WygenerujToken(uczestnik.LiveKitIdentity, uczestnik.Nick, RolaUzytkownika.Uczestnik, _nazwaPokojuGlowna);
_logger.LogInformation("Wydano token sceny glownej dla uczestnika {UczestnikId}.", uczestnik.Id);
return WynikOperacji.Ok(new OdpowiedzTokenuLiveKitDto(liveKitToken, _urlLiveKit));
}
///
/// Wspolna weryfikacja "czy ten, kto twierdzi ze jest uczestnikiem X, zna jego sekret sesji" --
/// uzywana przez oba publiczne endpointy uczestnika. Zly sekret i nieznany uczestnik dostaja
/// CELOWO ten sam komunikat, zeby nie ujawniac, czy dany identyfikator w ogole istnieje.
///
private async Task<(UczestnikOczekujacy? Uczestnik, string? Blad)> ZweryfikujSekretAsync(Guid uczestnikId, string sekret, CancellationToken token)
{
const string bladNieprawidloweDane = "Nieprawidlowy identyfikator uczestnika lub sekret sesji.";
if (string.IsNullOrWhiteSpace(sekret))
{
return (null, bladNieprawidloweDane);
}
var uczestnik = await _repozytoriumPoczekalni.ZnajdzPoIdAsync(uczestnikId, token);
if (uczestnik is null || uczestnik.SekretSesjiHash is null)
{
_logger.LogInformation("Weryfikacja sekretu sesji nieudana -- nieznany uczestnik {UczestnikId}.", uczestnikId);
return (null, bladNieprawidloweDane);
}
var wynikWeryfikacji = _uslugaHaszowaniaSekretowSesji.Weryfikuj(uczestnik, uczestnik.SekretSesjiHash, sekret);
if (wynikWeryfikacji == WynikWeryfikacjiHasla.Niepoprawne)
{
_logger.LogInformation("Weryfikacja sekretu sesji nieudana -- bledny sekret dla uczestnika {UczestnikId}.", uczestnikId);
return (null, bladNieprawidloweDane);
}
if (wynikWeryfikacji == WynikWeryfikacjiHasla.PoprawneWymagaRehaszowania)
{
// Tak samo jak przy hasle hosta (SerwisLogowania) -- przeliczamy hash "przy okazji" udanej
// weryfikacji, zeby nie wymuszac ponownego dolaczenia na uczestniku.
uczestnik.UstawSekretSesji(_uslugaHaszowaniaSekretowSesji.Haszuj(uczestnik, sekret));
await _jednostkaPracy.ZapiszZmianyAsync(token);
}
return (uczestnik, null);
}
///
/// Zwalnia slot w bazie i zwraca zdjetego uczestnika (albo null, gdy slot juz byl pusty) -- caller
/// wola nim TWARDE wymuszenie w LiveKit (ISerwisAdministracjiLiveKit.OdbierzUprawnieniaScenyAsync),
/// ale dopiero PO _jednostkaPracy.ZapiszZmianyAsync (tak samo jak SerwisMikrofonu: baza jest
/// autorytatywnym zrodlem prawdy, LiveKit to "przy okazji" best effort).
///
private async Task ZwolnijSlotWewnetrznieAsync(SlotSceny slot, CancellationToken token)
{
UczestnikOczekujacy? uczestnik = null;
if (slot.UczestnikOczekujacyId is Guid uczestnikId)
{
uczestnik = await _repozytoriumPoczekalni.ZnajdzPoIdAsync(uczestnikId, token);
uczestnik?.ZdejmijZeSceny();
}
slot.Zwolnij();
return uczestnik;
}
}