Panel prowadzenia: lista osob na sali i usuwanie po stronie serwera
Deploy PROD / deploy (push) Successful in 26s
Deploy PROD / deploy (push) Successful in 26s
Karta "Na sali" pokazuje liczbe i liste obecnych (przy LiveKit prosto z serwera pokoju, przy innych dostawcach z sygnalow obecnosci), z wyszukiwaniem, paginacja, wyciszeniem na czacie i usuwaniem z sali. Usuniecie uczestnika z zapisu blokuje go i wyrzuca przez removeParticipant; dotad sale opuszczala tylko posluszna przegladarka. Blokada z czatu robi to samo i pyta o potwierdzenie. Zablokowany nie pisze juz na czacie. Prowadzacego nie da sie usunac. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+12
-2
@@ -4,6 +4,7 @@ import com.softspm.webinar.auth.AuthenticatedUser;
|
||||
import com.softspm.webinar.domain.Interaction;
|
||||
import com.softspm.webinar.domain.InteractionStatus;
|
||||
import com.softspm.webinar.domain.InteractionType;
|
||||
import com.softspm.webinar.video.RoomPresenceService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -34,11 +35,14 @@ public class InteractionAdminController {
|
||||
|
||||
private final InteractionService interactions;
|
||||
private final InteractionBroadcaster broadcaster;
|
||||
private final RoomPresenceService presence;
|
||||
|
||||
public InteractionAdminController(InteractionService interactions,
|
||||
InteractionBroadcaster broadcaster) {
|
||||
InteractionBroadcaster broadcaster,
|
||||
RoomPresenceService presence) {
|
||||
this.interactions = interactions;
|
||||
this.broadcaster = broadcaster;
|
||||
this.presence = presence;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,7 +165,8 @@ public class InteractionAdminController {
|
||||
* Zablokowanie albo odblokowanie wejscia uczestnika na sale.
|
||||
*
|
||||
* Zablokowany nie wejdzie ponownie. Rozglaszamy na zywo, zeby zablokowany
|
||||
* opuscil sale od razu, a nie dopiero przy odswiezeniu.
|
||||
* opuscil sale od razu, a nie dopiero przy odswiezeniu, i usuwamy go z pokoju
|
||||
* po stronie serwera wideo.
|
||||
*/
|
||||
@PostMapping("/participants/{registrationId}/ban")
|
||||
@Operation(summary = "Zablokowanie albo odblokowanie wejscia na sale")
|
||||
@@ -170,6 +175,11 @@ public class InteractionAdminController {
|
||||
@RequestBody ModerationToggle request) {
|
||||
var registration = interactions.setRoomBanned(registrationId, request.value());
|
||||
broadcaster.participantModeration(id, request.value() ? "BANNED" : "UNBANNED", registrationId);
|
||||
if (request.value()) {
|
||||
// zdarzenie BANNED wyprowadza posluszna przegladarke; serwer pokoju
|
||||
// wyrzuca takze klienta, ktory na zdarzenie nie zareaguje
|
||||
presence.removeFromRoom(id, "reg-" + registrationId);
|
||||
}
|
||||
return new ModerationStateView(registration.getId(),
|
||||
registration.isChatMuted(), registration.isRoomBanned());
|
||||
}
|
||||
|
||||
@@ -87,10 +87,14 @@ public class InteractionService {
|
||||
// Sprawdzamy stan w bazie, a nie z principala: wyciszenie moglo nastapic
|
||||
// JUZ PO nawiazaniu polaczenia WebSocket, gdy principal byl ustalany.
|
||||
if (!author.speaker() && author.registrationId() != null) {
|
||||
boolean muted = registrationRepository.findById(author.registrationId())
|
||||
.map(com.softspm.webinar.domain.Registration::isChatMuted)
|
||||
.orElse(false);
|
||||
if (muted) {
|
||||
var registration = registrationRepository.findById(author.registrationId()).orElse(null);
|
||||
// zablokowany traci takze czat: polaczenie WebSocket nawiazane
|
||||
// przed blokada zyje dalej, bo interceptor sprawdza ja tylko przy CONNECT
|
||||
if (registration != null && registration.isRoomBanned()) {
|
||||
throw new ValidationException(
|
||||
"Prowadzący zablokował Ci dostęp do tego spotkania.");
|
||||
}
|
||||
if (registration != null && registration.isChatMuted()) {
|
||||
throw new ValidationException(
|
||||
"Prowadzący wyłączył Ci możliwość pisania na czacie.");
|
||||
}
|
||||
|
||||
@@ -50,6 +50,24 @@ public interface AttendanceSessionRepository extends JpaRepository<AttendanceSes
|
||||
""")
|
||||
List<AttendanceSession> findStale(@Param("threshold") OffsetDateTime threshold);
|
||||
|
||||
/**
|
||||
* Kto jest teraz na sali wedlug sygnalow obecnosci.
|
||||
*
|
||||
* Zrodlo listy obecnych przy dostawcach, u ktorych nie da sie odpytac
|
||||
* pokoju (NONE, Jitsi, Zoom). Autor pobierany od razu, bo lista trafia do
|
||||
* widoku w tej samej transakcji.
|
||||
*/
|
||||
@Query("""
|
||||
SELECT a FROM AttendanceSession a
|
||||
LEFT JOIN FETCH a.registration
|
||||
LEFT JOIN FETCH a.speaker
|
||||
WHERE a.webinar.id = :webinarId
|
||||
AND a.leftAt IS NULL
|
||||
AND a.lastSeenAt >= :threshold
|
||||
""")
|
||||
List<AttendanceSession> findPresent(@Param("webinarId") Long webinarId,
|
||||
@Param("threshold") OffsetDateTime threshold);
|
||||
|
||||
/**
|
||||
* Laczny czas obecnosci jednego uczestnika, w sekundach.
|
||||
*
|
||||
|
||||
@@ -43,6 +43,9 @@ public interface RegistrationRepository extends JpaRepository<Registration, Long
|
||||
|
||||
List<Registration> findByWebinarIdOrderByCreatedAtDesc(Long webinarId);
|
||||
|
||||
/** Zablokowani przez prowadzacego, do odblokowania z panelu prowadzenia. */
|
||||
List<Registration> findByWebinarIdAndRoomBannedTrueOrderByLastNameAscFirstNameAsc(Long webinarId);
|
||||
|
||||
List<Registration> findByWebinarIdAndStatusOrderByCreatedAtDesc(Long webinarId, RegistrationStatus status);
|
||||
|
||||
/*
|
||||
|
||||
@@ -424,7 +424,7 @@ public class LiveKitEgressService {
|
||||
|
||||
private EgressServiceClient egressClient() {
|
||||
return EgressServiceClient.createClient(
|
||||
httpUrl(apiUrl()),
|
||||
apiHttpUrl(),
|
||||
settings.get(SettingKey.LIVEKIT_API_KEY),
|
||||
settings.get(SettingKey.LIVEKIT_API_SECRET));
|
||||
}
|
||||
@@ -453,6 +453,17 @@ public class LiveKitEgressService {
|
||||
* w siebie, bo "localhost" wewnatrz kontenera to ten kontener. Dlatego
|
||||
* `LIVEKIT_INTERNAL_URL` ma pierwszenstwo, gdy jest ustawione.
|
||||
*/
|
||||
/**
|
||||
* Adres HTTP API serwera widziany przez backend.
|
||||
*
|
||||
* Wspolny dla nagrywania i dla obslugi pokoju (`RoomPresenceService`),
|
||||
* zeby oba odpytywaly ten sam serwer: rozjazd oznaczalby liste obecnych
|
||||
* z innego pokoju niz ten, ktory nagrywamy.
|
||||
*/
|
||||
String apiHttpUrl() {
|
||||
return httpUrl(apiUrl());
|
||||
}
|
||||
|
||||
private String apiUrl() {
|
||||
String internal = settings.get(SettingKey.LIVEKIT_INTERNAL_URL);
|
||||
if (internal != null && !internal.isBlank()) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.softspm.webinar.video;
|
||||
|
||||
import com.softspm.webinar.interaction.InteractionBroadcaster;
|
||||
import com.softspm.webinar.interaction.InteractionService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import com.softspm.webinar.web.ValidationException;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Obecni na sali w panelu prowadzenia.
|
||||
*
|
||||
* Sciezka pod /admin, wiec dostep maja ADMIN i MODERATOR: prowadzacy spotkanie
|
||||
* musi moc usunac kogos z sali bez szukania administratora.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/webinars/{id}/room/participants")
|
||||
@Tag(name = "Obecni na sali", description = "Lista osob na sali i usuwanie z niej")
|
||||
public class RoomPresenceController {
|
||||
|
||||
private final RoomPresenceService presence;
|
||||
private final InteractionService interactions;
|
||||
private final InteractionBroadcaster broadcaster;
|
||||
|
||||
public RoomPresenceController(RoomPresenceService presence,
|
||||
InteractionService interactions,
|
||||
InteractionBroadcaster broadcaster) {
|
||||
this.presence = presence;
|
||||
this.interactions = interactions;
|
||||
this.broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Lista osob na sali, stronicowana, z wyszukiwaniem")
|
||||
public RoomPresenceService.PresenceView list(@PathVariable Long id,
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "25") int size) {
|
||||
return presence.list(id, q, page, Math.min(size, 100));
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuniecie z sali.
|
||||
*
|
||||
* Uczestnik z zapisu dostaje BLOKADE, a dopiero potem jest wyrzucany: samo
|
||||
* wyrzucenie nic nie daje, bo wrocilby tym samym linkiem. Blokade zdejmuje
|
||||
* sie przyciskiem "Odblokuj" (ten sam endpoint co przy czacie).
|
||||
*
|
||||
* Polaczenie spoza zapisu (np. klient z kluczem API) jest tylko wyrzucane:
|
||||
* nie mamy czego zablokowac, bo nie przeszlo przez nasz bilet.
|
||||
*
|
||||
* Prowadzacego usunac nie mozna: webinar zostalby bez obrazu, a przycisk
|
||||
* klikniety omylkowo przy liscie dwustu osob jest realnym ryzykiem.
|
||||
*/
|
||||
@PostMapping("/remove")
|
||||
@Operation(summary = "Usuwa osobe z sali; uczestnika z zapisu takze blokuje")
|
||||
public RemoveResult remove(@PathVariable Long id, @Valid @RequestBody RemoveRequest request) {
|
||||
String identity = request.identity().trim();
|
||||
|
||||
if (identity.startsWith("spk-")) {
|
||||
throw new ValidationException("Prowadzącego nie można usunąć z sali.");
|
||||
}
|
||||
|
||||
Long registrationId = RoomPresenceService.idOf(identity, "reg-");
|
||||
boolean banned = false;
|
||||
if (registrationId != null) {
|
||||
// identyfikator przychodzi z zewnatrz: zapis musi nalezec do TEGO webinaru
|
||||
if (!presence.belongsTo(id, registrationId)) {
|
||||
throw new NotFoundException("Nie znaleziono uczestnika tego webinaru");
|
||||
}
|
||||
interactions.setRoomBanned(registrationId, true);
|
||||
broadcaster.participantModeration(id, "BANNED", registrationId);
|
||||
banned = true;
|
||||
}
|
||||
|
||||
boolean removed = presence.removeFromRoom(id, identity);
|
||||
return new RemoveResult(identity, banned, removed);
|
||||
}
|
||||
|
||||
public record RemoveRequest(@NotBlank String identity) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param removedFromRoom serwer pokoju potwierdzil usuniecie; false przy
|
||||
* dostawcy bez tej operacji albo gdy osoba juz wyszla
|
||||
*/
|
||||
public record RemoveResult(String identity, boolean banned, boolean removedFromRoom) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package com.softspm.webinar.video;
|
||||
|
||||
import com.softspm.webinar.domain.AttendanceSession;
|
||||
import com.softspm.webinar.domain.Registration;
|
||||
import com.softspm.webinar.domain.Webinar;
|
||||
import com.softspm.webinar.domain.WebinarSpeaker;
|
||||
import com.softspm.webinar.repository.AttendanceSessionRepository;
|
||||
import com.softspm.webinar.repository.RegistrationRepository;
|
||||
import com.softspm.webinar.repository.WebinarRepository;
|
||||
import com.softspm.webinar.repository.WebinarSpeakerRepository;
|
||||
import com.softspm.webinar.settings.SettingKey;
|
||||
import com.softspm.webinar.settings.SettingsService;
|
||||
import com.softspm.webinar.web.NotFoundException;
|
||||
import io.livekit.server.RoomServiceClient;
|
||||
import livekit.LivekitModels;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Kto jest teraz na sali i usuwanie z niej.
|
||||
*
|
||||
* DWA ZRODLA, JEDNA ODPOWIEDZ
|
||||
* ---------------------------------------------------------------------------
|
||||
* Przy LiveKit pytamy **serwer pokoju**: to jedyne miejsce, ktore wie, kto
|
||||
* faktycznie odbiera obraz. Widac tam takze polaczenia spoza zapisu (klient
|
||||
* z kluczem API, pozostalosc po tescie), ktorych nasza baza nie zna wcale.
|
||||
* Przy pozostalych dostawcach pokoju nie da sie odpytac, wiec lista pochodzi
|
||||
* z sygnalow obecnosci wysylanych przez strone spotkania co minute.
|
||||
* Pole `source` mowi panelowi, ktore zrodlo odpowiedzialo.
|
||||
*
|
||||
* USUNIECIE EGZEKWUJE SERWER, NIE PRZEGLADARKA
|
||||
* ---------------------------------------------------------------------------
|
||||
* Blokada ustawiona w bazie odcina nowe bilety, a zdarzenie BANNED kaze
|
||||
* przegladarce wyjsc. Posluszna przegladarka wychodzi, ale klient wlasny albo
|
||||
* zmodyfikowana strona zostalyby na sali z waznym biletem. Dlatego przy LiveKit
|
||||
* usuniecie idzie takze przez `removeParticipant` po stronie serwera pokoju.
|
||||
* Samo wyrzucenie bez blokady nie ma sensu: uczestnik wrocilby tym samym
|
||||
* linkiem po kilku sekundach.
|
||||
*/
|
||||
@Service
|
||||
public class RoomPresenceService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RoomPresenceService.class);
|
||||
|
||||
public static final String SOURCE_LIVEKIT = "LIVEKIT";
|
||||
public static final String SOURCE_ATTENDANCE = "ATTENDANCE";
|
||||
|
||||
public static final String KIND_SPEAKER = "SPEAKER";
|
||||
public static final String KIND_PARTICIPANT = "PARTICIPANT";
|
||||
public static final String KIND_OTHER = "OTHER";
|
||||
|
||||
private final WebinarRepository webinarRepository;
|
||||
private final RegistrationRepository registrationRepository;
|
||||
private final WebinarSpeakerRepository speakerRepository;
|
||||
private final AttendanceSessionRepository attendanceRepository;
|
||||
private final VideoProviderFactory providerFactory;
|
||||
private final LiveKitVideoProvider liveKit;
|
||||
private final LiveKitEgressService egressService;
|
||||
private final SettingsService settings;
|
||||
|
||||
public RoomPresenceService(WebinarRepository webinarRepository,
|
||||
RegistrationRepository registrationRepository,
|
||||
WebinarSpeakerRepository speakerRepository,
|
||||
AttendanceSessionRepository attendanceRepository,
|
||||
VideoProviderFactory providerFactory,
|
||||
LiveKitVideoProvider liveKit,
|
||||
LiveKitEgressService egressService,
|
||||
SettingsService settings) {
|
||||
this.webinarRepository = webinarRepository;
|
||||
this.registrationRepository = registrationRepository;
|
||||
this.speakerRepository = speakerRepository;
|
||||
this.attendanceRepository = attendanceRepository;
|
||||
this.providerFactory = providerFactory;
|
||||
this.liveKit = liveKit;
|
||||
this.egressService = egressService;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista obecnych, stronicowana i filtrowana po stronie serwera.
|
||||
*
|
||||
* Liczniki (`speakers`, `participants`, `others`) dotycza CALEJ sali, nie
|
||||
* biezacej strony ani wyniku wyszukiwania: prowadzacy ma widziec, ilu
|
||||
* ludzi slucha, niezaleznie od tego, czego akurat szuka.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public PresenceView list(Long webinarId, String query, int page, int size) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||
|
||||
List<ParticipantView> all;
|
||||
String source;
|
||||
String problem = null;
|
||||
|
||||
if (usesLiveKit(webinar)) {
|
||||
source = SOURCE_LIVEKIT;
|
||||
List<ParticipantView> fromRoom;
|
||||
try {
|
||||
fromRoom = fromLiveKit(webinar);
|
||||
} catch (Exception e) {
|
||||
// brak polaczenia z serwerem pokoju nie moze zaslonic reszty
|
||||
// panelu prowadzenia; pokazujemy liste z sygnalow obecnosci
|
||||
log.warn("Obecni: odpytanie LiveKit nieudane dla webinaru {}: {}", webinarId, e.getMessage());
|
||||
problem = "Nie udało się odpytać serwera wideo. Lista pochodzi z sygnałów obecności "
|
||||
+ "i może nie obejmować osób, które weszły przed chwilą.";
|
||||
source = SOURCE_ATTENDANCE;
|
||||
fromRoom = fromAttendance(webinar);
|
||||
}
|
||||
all = fromRoom;
|
||||
} else {
|
||||
source = SOURCE_ATTENDANCE;
|
||||
all = fromAttendance(webinar);
|
||||
}
|
||||
|
||||
// prowadzacy na gorze, potem mowiacy, potem alfabetycznie
|
||||
all = all.stream()
|
||||
.sorted(Comparator
|
||||
.comparing((ParticipantView p) -> !KIND_SPEAKER.equals(p.kind()))
|
||||
.thenComparing(p -> !(p.audio() || p.video() || p.screen()))
|
||||
.thenComparing(p -> p.name() == null ? "" : p.name().toLowerCase(Locale.ROOT)))
|
||||
.toList();
|
||||
|
||||
int speakers = (int) all.stream().filter(p -> KIND_SPEAKER.equals(p.kind())).count();
|
||||
int participants = (int) all.stream().filter(p -> KIND_PARTICIPANT.equals(p.kind())).count();
|
||||
int others = all.size() - speakers - participants;
|
||||
|
||||
List<ParticipantView> filtered = filter(all, query);
|
||||
int safeSize = Math.max(1, Math.min(size, 100));
|
||||
int totalPages = Math.max(1, (filtered.size() + safeSize - 1) / safeSize);
|
||||
int safePage = Math.max(0, Math.min(page, totalPages - 1));
|
||||
int from = safePage * safeSize;
|
||||
List<ParticipantView> items = filtered.subList(
|
||||
Math.min(from, filtered.size()), Math.min(from + safeSize, filtered.size()));
|
||||
|
||||
List<BannedView> banned = registrationRepository
|
||||
.findByWebinarIdAndRoomBannedTrueOrderByLastNameAscFirstNameAsc(webinarId).stream()
|
||||
.map(r -> new BannedView(r.getId(), fullName(r), r.getEmail()))
|
||||
.toList();
|
||||
|
||||
return new PresenceView(source, all.size(), speakers, participants, others,
|
||||
List.copyOf(items), safePage, safeSize, totalPages, filtered.size(),
|
||||
banned, problem, OffsetDateTime.now());
|
||||
}
|
||||
|
||||
/** Czy zapis nalezy do tego webinaru. Identyfikator przychodzi z zewnatrz. */
|
||||
@Transactional(readOnly = true)
|
||||
public boolean belongsTo(Long webinarId, Long registrationId) {
|
||||
return registrationRepository.findById(registrationId)
|
||||
.map(r -> r.getWebinar().getId().equals(webinarId))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuwa polaczenie z pokoju u dostawcy.
|
||||
*
|
||||
* Nietransakcyjne: wola serwer po sieci, a trzymanie w tym czasie
|
||||
* polaczenia z baza niczemu nie sluzy. Przy dostawcach bez takiej operacji
|
||||
* zwraca false; wtedy uczestnika z sali wyprowadza jego przegladarka po
|
||||
* zdarzeniu BANNED.
|
||||
*
|
||||
* @return czy serwer pokoju potwierdzil usuniecie
|
||||
*/
|
||||
public boolean removeFromRoom(Long webinarId, String identity) {
|
||||
Webinar webinar = webinarRepository.findById(webinarId)
|
||||
.orElseThrow(() -> new NotFoundException("Nie znaleziono webinaru"));
|
||||
if (!usesLiveKit(webinar)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
var response = roomClient()
|
||||
.removeParticipant(liveKit.roomIdOf(webinar), identity)
|
||||
.execute();
|
||||
if (response.isSuccessful()) {
|
||||
log.info("Obecni: usunieto {} z pokoju webinaru {}", identity, webinarId);
|
||||
return true;
|
||||
}
|
||||
// 404 to zwykle osoba, ktora zdazyla juz wyjsc sama
|
||||
log.info("Obecni: serwer nie usunal {} z webinaru {} (kod {})",
|
||||
identity, webinarId, response.code());
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
log.warn("Obecni: usuniecie {} z webinaru {} nieudane: {}", identity, webinarId, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Czy pokoj stoi u LiveKit.
|
||||
*
|
||||
* Pokoj juz zalozony rozstrzyga `ofExistingRoom`: po zmianie dostawcy
|
||||
* ludzie siedza jeszcze u poprzedniego. Bez zalozonego pokoju decyduje
|
||||
* wybor dla webinaru, bo LiveKit zaklada pokoj przy pierwszym wejsciu.
|
||||
*/
|
||||
private boolean usesLiveKit(Webinar webinar) {
|
||||
VideoProvider existing = providerFactory.ofExistingRoom(webinar);
|
||||
VideoProvider provider = existing != null ? existing : providerFactory.forWebinar(webinar);
|
||||
return LiveKitVideoProvider.NAME.equals(provider.name());
|
||||
}
|
||||
|
||||
private RoomServiceClient roomClient() {
|
||||
return RoomServiceClient.createClient(
|
||||
egressService.apiHttpUrl(),
|
||||
settings.get(SettingKey.LIVEKIT_API_KEY),
|
||||
settings.get(SettingKey.LIVEKIT_API_SECRET));
|
||||
}
|
||||
|
||||
private List<ParticipantView> fromLiveKit(Webinar webinar) throws java.io.IOException {
|
||||
var response = roomClient().listParticipants(liveKit.roomIdOf(webinar)).execute();
|
||||
if (response.code() == 404) {
|
||||
// pokoj powstaje przy pierwszym wejsciu, wiec przed nim nikogo nie ma
|
||||
return List.of();
|
||||
}
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IllegalStateException("kod " + response.code());
|
||||
}
|
||||
|
||||
List<LivekitModels.ParticipantInfo> infos = response.body().stream()
|
||||
// nagrywanie (Egress) wchodzi jako ukryty uczestnik: to nie osoba
|
||||
.filter(p -> !(p.hasPermission()
|
||||
&& (p.getPermission().getHidden() || p.getPermission().getRecorder())))
|
||||
.toList();
|
||||
|
||||
Map<Long, Registration> registrations = registrationRepository
|
||||
.findAllById(idsWithPrefix(infos, "reg-")).stream()
|
||||
.filter(r -> r.getWebinar().getId().equals(webinar.getId()))
|
||||
.collect(Collectors.toMap(Registration::getId, Function.identity()));
|
||||
Map<Long, WebinarSpeaker> speakers = speakerRepository
|
||||
.findAllById(idsWithPrefix(infos, "spk-")).stream()
|
||||
.collect(Collectors.toMap(WebinarSpeaker::getId, Function.identity()));
|
||||
|
||||
List<ParticipantView> result = new ArrayList<>();
|
||||
for (LivekitModels.ParticipantInfo info : infos) {
|
||||
boolean audio = false;
|
||||
boolean video = false;
|
||||
boolean screen = false;
|
||||
for (LivekitModels.TrackInfo track : info.getTracksList()) {
|
||||
if (track.getMuted()) continue;
|
||||
switch (track.getSource()) {
|
||||
case MICROPHONE, SCREEN_SHARE_AUDIO -> audio = true;
|
||||
case CAMERA -> video = true;
|
||||
case SCREEN_SHARE -> screen = true;
|
||||
default -> {
|
||||
if (track.getType() == LivekitModels.TrackType.AUDIO) audio = true;
|
||||
if (track.getType() == LivekitModels.TrackType.VIDEO) video = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
OffsetDateTime joinedAt = info.getJoinedAt() > 0
|
||||
? OffsetDateTime.ofInstant(Instant.ofEpochSecond(info.getJoinedAt()), ZoneOffset.UTC)
|
||||
: null;
|
||||
|
||||
String identity = info.getIdentity();
|
||||
Long regId = idOf(identity, "reg-");
|
||||
Long spkId = idOf(identity, "spk-");
|
||||
Registration registration = regId == null ? null : registrations.get(regId);
|
||||
WebinarSpeaker speaker = spkId == null ? null : speakers.get(spkId);
|
||||
|
||||
if (registration != null) {
|
||||
result.add(new ParticipantView(identity, fullName(registration), KIND_PARTICIPANT,
|
||||
registration.getId(), registration.getEmail(), joinedAt, audio, video, screen,
|
||||
registration.isChatMuted(), registration.isRoomBanned()));
|
||||
} else if (speaker != null) {
|
||||
result.add(new ParticipantView(identity, speaker.getFullName(), KIND_SPEAKER,
|
||||
null, speaker.getEmail(), joinedAt, audio, video, screen, false, false));
|
||||
} else {
|
||||
// polaczenie spoza naszej sciezki wejscia: nie ma go w bazie
|
||||
String name = info.getName().isBlank() ? identity : info.getName();
|
||||
result.add(new ParticipantView(identity, name, KIND_OTHER,
|
||||
null, null, joinedAt, audio, video, screen, false, false));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<ParticipantView> fromAttendance(Webinar webinar) {
|
||||
int timeout = Math.max(1, settings.getInt(SettingKey.ATTENDANCE_TIMEOUT_MINUTES));
|
||||
OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(timeout);
|
||||
|
||||
// jedna osoba moze miec kilka otwartych sesji (dwie karty), pokazujemy ja raz
|
||||
Map<String, ParticipantView> unique = new HashMap<>();
|
||||
for (AttendanceSession session : attendanceRepository.findPresent(webinar.getId(), threshold)) {
|
||||
Registration r = session.getRegistration();
|
||||
WebinarSpeaker s = session.getSpeaker();
|
||||
ParticipantView view;
|
||||
if (r != null) {
|
||||
view = new ParticipantView("reg-" + r.getId(), fullName(r), KIND_PARTICIPANT,
|
||||
r.getId(), r.getEmail(), session.getJoinedAt(), false, false, false,
|
||||
r.isChatMuted(), r.isRoomBanned());
|
||||
} else if (s != null) {
|
||||
view = new ParticipantView("spk-" + s.getId(), s.getFullName(), KIND_SPEAKER,
|
||||
null, s.getEmail(), session.getJoinedAt(), false, false, false, false, false);
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
unique.merge(view.identity(), view, (a, b) ->
|
||||
a.joinedAt() != null && b.joinedAt() != null && b.joinedAt().isBefore(a.joinedAt()) ? b : a);
|
||||
}
|
||||
return new ArrayList<>(unique.values());
|
||||
}
|
||||
|
||||
private static List<ParticipantView> filter(List<ParticipantView> all, String query) {
|
||||
if (query == null || query.isBlank()) {
|
||||
return all;
|
||||
}
|
||||
String needle = query.trim().toLowerCase(Locale.ROOT);
|
||||
return all.stream()
|
||||
.filter(p -> contains(p.name(), needle) || contains(p.email(), needle)
|
||||
|| contains(p.identity(), needle))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private static boolean contains(String value, String needle) {
|
||||
return value != null && value.toLowerCase(Locale.ROOT).contains(needle);
|
||||
}
|
||||
|
||||
private static List<Long> idsWithPrefix(List<LivekitModels.ParticipantInfo> infos, String prefix) {
|
||||
return infos.stream()
|
||||
.map(p -> idOf(p.getIdentity(), prefix))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Identyfikator z tozsamosci `reg-12` / `spk-3`, null gdy format sie nie zgadza. */
|
||||
static Long idOf(String identity, String prefix) {
|
||||
if (identity == null || !identity.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(identity.substring(prefix.length()));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String fullName(Registration r) {
|
||||
return ((r.getFirstName() == null ? "" : r.getFirstName()) + " "
|
||||
+ (r.getLastName() == null ? "" : r.getLastName())).trim();
|
||||
}
|
||||
|
||||
// --- widoki ---
|
||||
|
||||
/**
|
||||
* @param source LIVEKIT (serwer pokoju) albo ATTENDANCE (sygnaly obecnosci)
|
||||
* @param total wszyscy na sali, bez wzgledu na filtr
|
||||
* @param problem opis bledu zrodla glownego, null gdy wszystko poszlo
|
||||
*/
|
||||
public record PresenceView(String source, int total, int speakers, int participants, int others,
|
||||
List<ParticipantView> items, int page, int size, int totalPages,
|
||||
long totalElements, List<BannedView> banned, String problem,
|
||||
OffsetDateTime checkedAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Jedna osoba na sali.
|
||||
*
|
||||
* @param kind SPEAKER, PARTICIPANT albo OTHER (polaczenie spoza zapisu)
|
||||
* @param registrationId wypelnione wylacznie dla uczestnika z zapisu
|
||||
* @param audio nadaje dzwiek teraz, nie "ma prawo nadawac"
|
||||
*/
|
||||
public record ParticipantView(String identity, String name, String kind, Long registrationId,
|
||||
String email, OffsetDateTime joinedAt,
|
||||
boolean audio, boolean video, boolean screen,
|
||||
boolean chatMuted, boolean roomBanned) {
|
||||
}
|
||||
|
||||
public record BannedView(Long registrationId, String name, String email) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user